29. MongoDB – baza danych dla backendu

Wyzwania:

  • dowiesz się, dlaczego warto korzystać z baz danych,
  • poznasz technologie MongoDB oraz Mongoose,
  • dowiesz się czym jest relacyjność danych,
  • wykorzystasz nową wiedzę w praktyce.

W poprzednich modułach wszelkie dane przechowywaliśmy w zwykłych tablicach. Takie rozwiązanie sprawdzało się podczas robienia ćwiczeń i prostych zadań, ale zapewne domyślasz się, że ma też swoje ograniczenia.

Po pierwsze, dane przechowywane w tablicy są ulotne. Możesz wykonać na nich mnóstwo operacji, a wszystko i tak zniknie po wyłączeniu aplikacji. Przykładem jest nasza witryna festiwalu z poprzedniego modułu. Po uruchomieniu mogliśmy rezerwować miejsca i dopóki serwer działał, wszystko było w porządku – odnotowywał on rezerwacje i zapisywał je w tablicy. Wystarczył jednak restart serwera i... wszystko wracało do stanu początkowego. W takiej sytuacji tablica jest inicjowana od nowa, z domyślnymi danymi.

Po drugie, wyszukiwanie oraz modyfikowanie danych w skomplikowanych zbiorach jest bardzo niewygodne. Powiedzmy, że chcesz znaleźć w tablicy db.persons tylko takie obiekty, które odpowiadają kilku warunkom – np. szukamy mężczyzn w wieku powyżej 18 lat, o imieniu innym niż John. Aby to osiągnąć, musielibyśmy wykorzystać metodę filter:

const filteredPersons = db.persons.filter(person => (person.age > 18 && person.name !== 'John' && person.gender === 'M'));

Nie ma tu tragedii, ale pewnie czujesz w głębi duszy, że istnieje jakieś lepsze rozwiązanie.

Jeszcze gorzej wyglądałoby to w przypadku próby usuwania danych. Jeśli chcielibyśmy modyfikować tablicę bezpośrednio, to użycie filter nie wchodziłoby w grę. Zamiast tego, musielibyśmy użyć np. forEach, tak aby sprawdzić każdy element z osobna i usunąć odpowiedni za pomocą splice. W tej sytuacji skrypt byłby jeszcze dłuższy:

db.persons.forEach((person, i) => {
  if(person.age > 18 && person.name !== 'John' && person.gender === 'M') {
    db.persons.splice(i, 1);
  }
});

Chcemy tylko usunąć kilka elementów z naszej "bazy danych" (tablicy), a otrzymujemy aż pięć linijek kodu. W przypadku bardziej skomplikowanych zbiorów danych i zaawansowanych operacji skrypt mógłby być jeszcze dłuższy.

Po trzecie, nie możemy z góry narzucić, jak powinny wyglądać dane w tablicy. Bardzo często chcielibyśmy, aby atrybuty wszystkich obiektów, jak i typy danych, były identyczne. Żeby osiągnąć taką spójność, musimy jednak pilnować się sami.

Poprzednio każdy obiekt w naszej kolekcji concerts posiadał dokładnie takie same atrybuty:

concerts: [
  { id: 1, performer: 'John Doe', genre: 'Rock', price: 25, day: 1, image: '/img/uploads/1fsd324fsdg.jpg' },
  { id: 2, performer: 'Rebekah Parker', genre: 'R&B', price: 25, day: 1, image: '/img/uploads/2f342s4fsdg.jpg' },
  { id: 3, performer: 'Maybell Haley', genre: 'Pop', price: 40, day: 1, image: '/img/uploads/hdfh42sd213.jpg' }
],

Jeśli jednak przypadkiem zrobilibyśmy błąd i zamiast day wpisali np. dat, to tablica przyjęłaby takie dane. Co więcej, zapisałaby również każde inne...

Dobrą opcją byłoby narzucenie z góry schematu atrybutów i typów, którego dane powinny się trzymać, tak aby nie można było wprowadzić niepoprawnego elementu do kolekcji. Niestety, tablice tego nie umożliwiają.

Po czwarte, tablice nie oferują nam żadnej relacyjności. Co mamy tu na myśli? Przypomnij sobie przykład z witryną festiwalu. Mieliśmy tam kolekcje concerts i seats, które w pewien sposób są ze sobą powiązane. Spójrz tylko:

...
concerts: [
  { id: 1, performer: 'John Doe', genre: 'Rock', price: 25, day: 1, image: '/img/uploads/1fsd324fsdg.jpg' },
  { id: 2, performer: 'Rebekah Parker', genre: 'R&B', price: 25, day: 1, image: '/img/uploads/2f342s4fsdg.jpg' },
  { id: 3, performer: 'Maybell Haley', genre: 'Pop', price: 40, day: 1, image: '/img/uploads/hdfh42sd213.jpg' }
],
seats: [
  { id: 1, day: 1, seat: 3, client: 'Amanda Doe', email: 'amandadoe@example.com' },
  { id: 2, day: 1, seat: 9, client: 'Curtis Johnson', email: 'curtisj@example.com' },
  { id: 3, day: 1, seat: 10, client: 'Felix McManara', email: 'felxim98@example.com' },
  { id: 4, day: 1, seat: 26, client: 'Fauna Keithrins', email: 'mefauna312@example.com' },
  { id: 5, day: 2, seat: 1, client: 'Felix McManara', email: 'felxim98@example.com' },
  { id: 6, day: 2, seat: 2, client: 'Molier Lo Celso', email: 'moiler.lo.celso@example.com' }
]

Każde miejsce ma atrybut day, który odpowiada elementowi z tablicy concerts – np. day równy 1 wskazuje, że dane miejsce jest zarezerwowane na koncert Johna Doe. My jesteśmy w stanie zauważyć takie powiązanie, ale JS niekoniecznie – dla niego są to dwie kompletnie różne kolekcje.

Z łatwością możemy wyobrazić sobie jednak sytuację, w której takie powiązanie byłoby praktyczne. Przydałaby się np. metoda, która potrafiłaby połączyć nasze dane tak, iż przy pobraniu seats, w miejscu atrybutu day pojawiałoby się imię wykonawcy.

W takiej sytuacji moglibyśmy łatwo pobierać różne dane w formie jednej tablicy, mimo tego, że rzeczywisty podział jest trochę bardziej skomplikowany. Tablice niestety nie zaoferują nam takiej opcji.

Na te wszystkie bolączki jest jednak odpowiedź – bazy danych MongoDB wraz ze wsparciem Mongoose.

29.1. Poznajemy MongoDB

Odpowiedzią na ograniczenia zwykłych tablic jest użycie baz danych, które z założenia przechowują informacje niezależnie od aplikacji, a ich zasoby nie są ulotne.

Ponadto każdy silnik bazy danych oferuje interfejs, dzięki któremu można łatwo pobierać, wyszukiwać czy modyfikować informacje. Nie mówimy tutaj o zwykłym "przechodzeniu" po liście obiektów, tylko o gotowych metodach, które najczęściej bardzo dużo robią za nas.

MongoDB jest jednym z wielu silników dostępnych na rynku, wyróżnia się jednak konstrukcją oraz sposobem przechowywania danych. Komunikacja z bazą bardzo przypomina to, co znamy już z JS-a (niedługo się o tym przekonasz). W dodatku same dane są przechowywane w formacie podobnym do znanego nam już JSON-a (tzw. BSON). Powoduje to, że MongoDB jest idealnym wyborem w przypadku aplikacji pisanych w Node.js. Potrafi dobrze z nimi współpracować, a z racji podobnej budowy, jest stosunkowo łatwy w nauce.

Przy okazji – nie bez powodu napisaliśmy "Node.js", a nie "JS". Wiesz już po poprzednich modułach, że klient nie komunikuje się z bazą danych. To rola serwera, który tworzymy w końcu z wykorzystaniem Node.js (nie zapominaj, że Express jest tylko frameworkiem).

Alternatywny wybór

Jeśli tematyka baz danych nie jest Ci obca, to bardzo możliwe, że było Ci dane pracować z dwoma innymi popularnymi technologiami – SQL lub MySQL. Moglibyśmy wykorzystać je zamiast MongoDB, jednak z racji tego, że dane są w nich przechowywane w trochę inny sposób, a sam interfejs do komunikacji to kompletnie nowa idea (język deklaratywny), byłoby to o wiele mniej wygodne. Nawet sama podstawowa konfiguracja zajęłaby nam o wiele więcej czasu.

Podsumujmy, do czego przyda się MongoDB?

Po pierwsze, da nam możliwość przechowywania dużej ilości danych w nieulotny sposób, więc restart aplikacji nie będzie oznaczać utraty wprowadzonych zmian. Do tego dane będą przechowywane niezależnie od aplikacji. Owszem, będzie miała do nich dostęp i możliwość modyfikacji, ale jej wyłączenie lub ponownie uruchomienie nie wpłynie na spójność naszych danych.

Po drugie, MongoDB oferuje nam również przyjazny interfejs do komunikacji. Oznacza to, że wyszukiwanie danych czy ich modyfikacja będą możliwe przy użyciu prostych komend. Usunięcie osób z kolekcji persons, których wiek jest równy 18 lat, wyglądałoby tak: db.persons.remove({ age: 18 }) – tylko jedna komenda. W przypadku tablic, jak zapewne pamiętasz, tych linijek byłoby 5.

Brzmi dobrze? Już wkrótce przekonasz się, że w praktyce również nie powinno sprawić Ci to problemu.

Tak naprawdę możemy traktować MongoDB jako bardziej zaawansowaną i niezależną alternatywę dla zwykłych tablic.

Pojawia się tylko jedno pytanie. Powiedzieliśmy o kilku zaletach MongoDB, ale nie rozwiązują one wszystkich problemów, o których była mowa we wprowadzeniu. Nie wspomnieliśmy, czy zaoferuje nam możliwość narzucania z góry struktury danych. Nie zająknęliśmy się również o relacyjności. Dlaczego? Dlatego, że akurat takich funkcjonalności samo MongoDB nie oferuje. Do tego będzie nam jeszcze potrzebna dodatkowa biblioteka – Mongoose. Na razie nie zawracaj sobie jednak tym głowy. Mongoose zajmiemy się później, teraz czas na poznanie MongoDB.

Instalacja MongoDB

Zaczniemy od instalacji. Wejdź na stronę mongodb.com i wybierz zakładkę "Server", następnie wskaż wersję 4.2.0 oraz swój system i kliknij przycisk "Download". Możesz wybrać nowszą edycję, jeśli w momencie czytania tego materiału będzie dostępna, aczkolwiek wszystkie snippety z kodem, które pojawią się niniejszym module, były sprawdzone w wersji 4.2.0. Zalecamy więc użycie właśnie jej.

MongoDB Compass

Wraz z MongoDB domyślnie pobierany jest również MongoDB Compass. To przyjazna aplikacja, która pozwala na przeglądanie baz danych za pomocą graficznego interfejsu. Opowiemy o niej trochę więcej później.

Uwaga!

Możesz natrafić na dwa problemy.

Pierwszy z dokończeniem instalacji. Proces czasami potrafi zatrzymać się na punkcie "Installing Compass". W takiej sytuacji po prostu przerwij działanie instalatora (sam MongoDB powinien być już w tym momencie zainstalowany) i pobierz Compass indywidualnie. Możesz to zrobić pod tym linkiem. Wybierz koniecznie Community Edition Stable.

Drugi z brakiem katalogu data/db. MongoDB chce przechowywać dane w folderze /data/db, który powinien znajdować w katalogu domowym (np. C:\data\db dla Windowsa). Musisz go więc utworzyć, aby MongoDB był w stanie uruchomić serwer bazy danych.

Pamiętaj, że katalog /data/db musi być stworzony w głównym katalogu systemu (root). Konieczne może być więc skorzystanie z komendy sudo przy tworzeniu folderu oraz nadanie mu potem odpowiednich uprawnień (za pomocą komendy chown). Muszą to być na tyle duże uprawnienia, aby MongoDB mogło odczytywać, zapisywać, modyfikować i usuwać dane z tego folderu. W przypadku Maca alternatywą może być pobranie całego pakietu za pomocą Homebrew.

Dostęp do MongoDB

Aby dodać nową bazę albo wyszukiwać i modyfikować dane, musimy w jakiś sposób komunikować się z MongoDB. Możemy to robić na kilka sposobów.

  • Graficzny interfejs – mowa tu np. o wcześniej wspomnianym Compassie, który umożliwia łatwą modyfikację bazy danych.
  • Node.js – wiemy, że serwery komunikują się z bazami danych, zatem jest to możliwe z poziomu aplikacji Node.js.
  • Konsola – wraz z samym MongoDB na naszym komputerze instaluje się prosta aplikacja konsolowa (tzw. Mongo Shell), która pozwala na bezpośrednią komunikację z bazami danych za pomocą prostych komend.

Pierwszy sposób pełni głównie rolę kontrolną. Przyda nam się, gdy będziemy np. chcieli sprawdzić, czy nasza aplikacja Node.js poprawnie zapisuje dane w bazie, albo czy dobrze je zmodyfikowała.

Jak się domyślasz, najczęściej sięgniemy po drugi sposób. W końcu po to nam bazy danych – mają być zbiornikiem informacji dla naszych zaawansowanych aplikacji. Chcemy sprawdzić ilość wolnych miejsc na koncercie albo zrobić rezerwację? Serwer połączy się z bazą danych, pobierze informacje i je nam przekaże, albo zrobi odpowiedni wpis w bazie.

Sposób z konsolą raczej nie będzie przez nas wykorzystywany w normalnej pracy. Użyjemy go jednak teraz, aby pokazać Ci, jeszcze bez aplikacji, jakie komendy udostępnia MongoDB w celu komunikacji z bazą danych.

Tworzymy nową bazę

Najczęściej będziemy porozumiewać się z bazą danych z poziomu samego serwera naszej aplikacji. Na razie jednak poznamy MongoDB, komunikując się z nim przez konsolę.

Odnajdź teraz folder, w którym zainstalowano MongoDB, a w nim katalog bin oraz dwa pliki: mongod.exe i mongo.exe.

Zadaniem mongod.exe jest uruchomienie serwera bazy danych i powinien on włączać się automatycznie wraz ze startem systemu. Gdy działa, możemy komunikować się z bazą z poziomu aplikacji, Compassa, czy też po prostu konsoli. To ważne, zawsze kiedy chcesz łączyć się z lokalną bazą danych, ten proces musi być uruchomiony. Gdy pojawi się w przyszłości problem przy połączeniu, to upewnij się, że ten proces jest włączony – jeśli nie, wystartuj go ręcznie.

Teraz uruchom drugi plik, mongo.exe, czyli konsolową aplikację do komunikacji z bazami danych MongoDB.

Zacznijmy od sprawdzenia, jakie zasoby aktualnie znajdują się na naszym dysku, warto wiedzieć bowiem, że na jednym komputerze możemy przechowywać więcej niż jedną bazę danych. To istotne, bo przecież każda aplikacja może mieć swoją własną. Na przykład nasza witryna festiwalu mogłaby korzystać z bazy o nazwie NewWaveApp i posiadać dwie kolekcje – seats i concerts, a aplikacja listy zadań – columns i cards.

Komenda, która wskaże nam aktualnie dostępne bazy to:

show dbs

Pewnie spodziewasz się, że dostaniemy komunikat o braku jakiejkolwiek bazy danych. Nic bardziej mylnego. Do zapisywania informacji o swojej konfiguracji, użytkownikach bazy i hasłach, MongoDB używa... baz danych, stąd od razu lista trzech istniejących – admin, config oraz local. Nie musimy się nimi przejmować. Po prostu pamiętaj, że nie są one Twoim wytworem, a MongoDB wykorzystuje je do zapisywania własnych danych.

image

Teraz czas na dodanie nowej, testowej bazy danych. Nazwiemy ją companyDB. Powiedzmy, że będzie ona zawierała informacje o jakiejś firmie i pomieści kolekcje na temat pracowników, stanowisk, działów itd.

Co ciekawe, w MongoDB nie istnieje komenda do samego tworzenia nowej bazy danych. Zamiast tego używamy:

use <db-name>

Polecenie to służy do wskazania bazy danych, na której aktualnie chcemy działać, ale jednocześnie potrafi utworzyć nową, jeśli jeszcze jej nie ma. Gdy baza już istnieje, zostanie wybrana i udostępniona nam w obiekcie db.

Wpisz teraz następującą komendę w konsoli (mongo.exe):

use companyDB

Skoro jeszcze takiej bazy danych nie ma, MongoDB ją utworzy, a następnie wybierze (stąd też komunikat "Switched to...").

Od razu możemy przygotować również kolekcję danych. Służy do tego metoda createCollection, która wygląda tak:

db.createCollection(<collection-name>)

My wygenerujemy trzy kolekcje danych employees, deparatments i products. Utworzenie pierwszej z nich nastąpi dzięki komendzie:

db.createCollection('employees')

Możesz to potraktować jak rozkaz: do aktualnie wybranej bazy danych (db = companyDB) dodaj nową kolekcję o nazwie employees.

Analogicznie dodaj też nowe kolekcje departments i products.

Upewnij się

Aby sprawdzić, czy kolekcje faktycznie zostały dodane poprawnie, możesz skorzystać z komendy show collections. Pokaże Ci ona wszystkie kolekcje dostępne w aktualnie wybranej bazie.

Nasza baza danych companyDB ma w tej chwili następujący układ:

image

Jak zapewne udało Ci się zauważyć na grafice – aktualne dane z każdej kolekcji to puste tablice. Wspomnieliśmy już wcześniej, że MongoDB został zbudowany z myślą o komforcie developerów zaznajomionych z JS-em oraz formatem JSON. Kolekcje są więc zapisane jako tablice, a same konkretne elementy (w MongoDB nazywane dokumentami) w formacie BSON, który jest bardzo podobny do znanego Ci już JSON-a.

Tak naprawdę niewiele zmieni się w naszym podejściu do pracy z danymi, teraz będą już jednak nieulotne, przechowywane niezależnie od aplikacji i dostaniemy się do nich za pomocą prostych komend.

Operacje CRUD

Podstawą każdego silnika bazy danych jest oferowanie interfejsu komunikacyjnego, który pozwoli na kontakt z bazą i jej modyfikację. MongoDB daje nam takie możliwości przy użyciu zwykłych komend. Kilka z nich udało Ci się już poznać. Wiesz, że show dbs pokazuje listę baz danych na komputerze, use <db-name> pozwala wybrać bazę, na której chcemy aktualnie pracować, a db.createCollection utworzy nową kolekcję ("kolekcja" odnosi się po prostu do tablicy danych).

Oprócz tego każdy silnik musi oferować obowiązkowo zestaw poleceń do modyfikacji samych danych. W końcu, jaki sens miałaby baza danych, w której nie można nic zmieniać? Zestaw komend do wprowadzania, usuwania, modyfikacji oraz przeszukiwania określamy mianem CRUD.

CRUD to po prostu akronim od:

  • Create – baza danych musi oferować możliwość dodawania nowych danych,
  • Read – musi mieć także możliwość ich odczytu,
  • Update – niezbędna jest również opcja aktualizacji,
  • Delete – koniecznie musimy mieć też możliwość usuwania danych.

Oczywiście każdy silnik bazy danych może oferować o wiele więcej możliwości, ale operacje typu CRUD to absolutna podstawa. Bez tych czterech funkcjonalności obsługa bazy danych byłaby zwyczajnie niemożliwa, dlatego teraz właśnie nimi się zajmiemy.

Create

Zaczniemy od tworzenia danych.

MongoDB oferuje nam od razu dwie możliwości:

  • insertOne – służy do wstawienia jednego nowego dokumentu (elementu) do wybranej kolekcji (tablicy),
  • insertMany – pozwala na wstawienie wielu dokumentów do kolekcji.

Każdej z metod zestawu CRUD będziemy używać w następujący sposób:

db.<collection-name>.<method>

czyli np.

db.employees.insertOne({ firstName: 'John', lastName: 'Doe' });

Przyznaj, że to dość intuicyjny zapis. Wybieramy kolekcję i mówimy, co chcemy z nią zrobić. Brzmi to niczym rozkaz: w aktualnie wybranej bazie danych odnajdź kolekcję employees i wstaw do niej nowy dokument ({ firstName: 'John', lastName: 'Doe' }).

Jak widzisz, insertOne przyjmuje po prostu jeden obiekt i wprowadza go do danej kolekcji. W przypadku insertMany jako argument przekazywalibyśmy tablicę obiektów do wprowadzenia. Na przykład:

db.employees.insertMany([{ firstName: 'John', lastName: 'Doe'}, { firstName: 'Amanda', lastName: 'Doe' }]);

Teraz czas na Ciebie. Przy wykorzystaniu metod insertMany lub insertOne, bazując na powyższych przykładach, dodaj do kolekcji następujące dokumenty:

employees

{
  firstName: 'John',
  lastName: 'Doe',
  department: 'IT'
},
{
  firstName: 'Amanda',
  lastName: 'Doe',
  department: 'Marketing'
},
{
  firstName: 'Jonathan',
  lastName: 'Wilson',
  department: 'IT'
},
{
  firstName: 'Thomas',
  lastName: 'Jefferson',
  department: 'Testing'
},
{
  firstName: 'Emma',
  lastName: 'Cowell',
  department: 'Testing'
}

departments

{ name: 'IT' },
{ name: 'Marketing' },
{ name: 'Testing' }

products

{
  name: 'New Wave Festival',
  client: 'MyMusicWave Corp.'
},
{
  name: 'ImRich Banking official website',
  client: 'ImRich LTD'
}

Uwaga!

MongoDB stara się być bardzo pomocne, do tego stopnia, że nawet jeśli pomylisz się i zrobisz literówkę w nazwie kolekcji, a więc zwyczajnie wybierzesz kolekcję nieistniejącą, to podobnie jak było to z wybraniem samej bazy danych, zostanie ona automatycznie utworzona.

Jeśli doszło do takiej sytuacji, zawsze możesz skorzystać z komendy drop. Potrafi ona usunąć wybraną (i np. błędnie nazwaną) kolekcję.

db.emplyes.drop()

Po wszystkich operacjach Twoja baza danych powinna wyglądać następująco:

image

Jak sprawdzić, czy rzeczywiście tak wygląda? Przyda się nam do tego metoda find.

Read

Drugą operacją, którą się zajmiemy, jest odczytywanie danych. Tutaj MongoDB daje nam bardzo szerokie możliwości, aby łatwo i precyzyjnie wybierać informacje nawet z ogromnych zbiorów.

Zacznijmy od początku. Interesują nas przede wszystkim dwie komendy:

  • find – służy do wyszukiwania wielu dokumentów,
  • findOne – szuka tylko jednego pasującego elementu.

W przypadku find dostaniemy tablicę z jednym elementem, wieloma lub pustą – w zależności od tego ile elementów spełni nasze warunki wyszukiwania. Można kojarzyć sobie rolę tej funkcji z filter, którą znasz z JS-a. Zadanie tamtej metody było bardzo podobne.

Stosując findOne, szukamy tylko jednego elementu. Jeśli baza zawiera jeden pasujący, to właśnie on zostanie zwrócony. W sytuacji, gdy będzie ich więcej, zwrócony zostanie pierwszy, który spełnia wymagania. Jeśli nie uda się znaleźć odpowiedniego elementu, metoda find zwróci po prostu null.

Jeśli chodzi o samo użycie metody, nie będzie się ono różniło od tego, co znamy już z insertOne czy insertMany. To wciąż ten sam koncept – db.<collection-name>.<method>.

Na przykład:

db.employees.findOne()
db.employees.find()

Pozostaje nam jeszcze kwestia warunków. Jeśli użyjemy findOne i find bez argumentu, to pierwsza metoda zwróci nam po prostu pierwszy dokument z kolekcji, a druga wszystkie. Przekonaj się o tym na własną rękę.

W ramach ćwiczenia uruchom po kolei:

db.employees.find()
db.departments.find()
db.products.find()

Konsola powinna zwrócić Ci całą zawartość każdej z kolekcji (tablicy).

Automatyczne id

Zapewne udało Ci się zauważyć, że zwrócone dane są trochę inne niż te, które dodawaliśmy. Każdy dokument otrzymał jedno nowe pole (atrybut), w postaci _id. Jest ono automatycznie nadawane dla każdego dokumentu jako unikalny identyfikator. To bardzo pożyteczna opcja, bo zazwyczaj i tak sami chcielibyśmy dodawać taki atrybut. MongoDB robi to za nas.

Opcja find(), która pokazuje nam wszystkie rekordy, jest przydatna, gdy np. chcemy zobaczyć całą zawartość danej kolekcji. Często zdarza się jednak, że potrzebujemy tylko jakąś część wyników, np. w aplikacji do zarządzania firmą, chcielibyśmy listować pracowników działu IT. Wtedy wolelibyśmy pobrać z bazy tylko dokumenty dotyczące tej grupy osób, a nie wszystkie. Na szczęście MongoDB daje nam takie możliwości. Co więcej, są one naprawdę szerokie. Nasze filtry mogą być bardzo precyzyjne, a wszystkie będziemy umieszczać jako atrybuty w argumencie funkcji.

Na przykład:

db.employees.find({
  firstName: 'John',
  department: { $ne: 'IT' }
});

Argumentem metody jest po prostu obiekt, w którym możemy ustalić jakie atrybuty chcemy sprawdzać i w jaki sposób. Powyższy przykład powinien szukać pracowników o imieniu John, pracujących w dziale innym niż IT ($ne to skrót od "negacji").

Poniżej omówimy dokładnie jak budować prostsze i trudniejsze warunki.

Konkretny warunek

Najprostsza sytuacja jest wtedy, kiedy chcemy wyszukać element, którego atrybuty mają jakąś konkretną wartość – nie większą czy mniejszą, albo "podobną", tylko dokładnie taką samą. W takim przypadku jako szukaną wartość atrybutu wystarczy wskazać konkretną informację. Robiliśmy to już wyżej, szukając pracowników o imieniu John.

Na przykład:

db.employees.find({ department: 'IT' });

zwróci nam tylko pracowników działu IT.

A taka instrukcja:

db.employees.findOne({ firstName: 'John', department: 'IT' });

będzie szukała pracownika, który nie tylko należy do działu IT, lecz również ma na imię John.

Przyznaj – to dość intuicyjne, prawda?

Mniej jasna sytuacja – używamy operatorów

Oczywiście nie zawsze sytuacja jest aż tak prosta. Często chcielibyśmy, aby te warunki były mniej konkretne, np. poszukujemy osoby poniżej 18 lat, czy też szukamy pracowników, którzy zarabiają od 2000 do 4000 tysięcy, albo – to, co już pokazaliśmy wcześniej – chcemy wyszukać dokumenty, których jakiś atrybut ma wartość inną niż wskazana przez nas (przykład z negacją).

W takich sytuacjach skorzystamy z szerokiej gamy dostarczonych przez MongoDB operatorów. Całą ich listę możesz znaleźć pod tym linkiem.

Poniżej przedstawiamy tylko niektóre z nich:

  • $eq – wartość atrybutu musi być dokładnie taka jak wskażemy, np. { age: { $eq: 21 }}. Oczywiście { age: 21 } też byłby tutaj poprawnym wyborem. $eq ma pewną przewagę nad zapisem bez operatora w kwestii bezpieczeństwa, ale nie będziemy poruszać tego tematu w tym module.
  • $gt – wartość atrybutu musi być większa niż podana, np. { age: { $gt: 18 }} zadziała jak warunek if(age > 18).
  • $gte – wartość atrybutu musi być większa lub równa podanej, np. age: { $gte: 18 } (warunek if(age >= 18)).
  • $in – wartość atrybutu musi być równa jednemu z możliwych wyborów. Możemy wskazać tablicę kilku opcji i jeśli chociaż jedna z nich odpowiada wartości atrybutu dokumentu, to będzie on zwrócony przy wyszukiwaniu, np. { hobby: { $in: ['sports', 'movies'] }} (to jak warunek if(hobby === 'sports' || hobby === 'movies').
  • $lt – wartość atrybutu musi być mniejsza niż podana, np. { age: { $lt: 18 }} (to jak warunek if(age < 18)).
  • $lte – wartość atrybutu musi być mniejsza lub równa podanej, np. { age: { $lte: 18 }} (to jak warunek if(age <= 18)).
  • $ne – wartość musi być inna niż podana (negacja), np. { firstName: { $ne: 'John' }} (to jak warunek if(firstName !== 'John')).
  • $nin – negacja $in. Wartość atrybutu nie może pasować do żadnego z podanych wyborów. Wskazujemy tablicę elementów i jeśli dany atrybut ma wartość pasującą do jednego z nich, to element nie będzie zwrócony.

Samo użycie operatorów pokazywaliśmy już wyżej w przykład z $ne. Dla jasności napiszmy to jednak jeszcze raz. W przypadku użycia operatorów, zamiast konkretnej wartości przy atrybucie, wstawiamy po prostu obiekt z operatorem.

Na przykład:

db.employees.find({
  department: 'IT',
  salary: { $gt: 2000 }
});

W tym przypadku znajdziemy pracowników, których atrybut department jest równy IT, ale pensja może być dowolna, o ile jest większa od 2000. Oczywiście ten przykład nie miałby sensu w naszej bazie danych, bo nie ma takiego pola jak salary.

db.employees.find({
  department: { $nin: ['IT', 'Marketing'] }
});

Powyższa instrukcja wyszuka pracowników każdego innego działu niż IT czy Marketing.

Zwiększamy czytelność

Domyślnie find oraz findOne pokazuje rezultat w formie długiego ciągu bez żadnego formatowania. Jeśli chcesz, żeby MongoDB przedstawił Ci znalezione dane w trochę ładniejszy sposób, to wystarczy po pierwszej metodzie użyć jeszcze kolejnej – pretty. Zadba ona o odpowiednie wcięcia i podział na linie.

Wykorzystuje się ją w ten sposób:

db.employees.find().pretty();

W ramach treningu wykonaj następujące ćwiczenia.

Ćwiczenie 1

Wyszukaj w kolekcji employees wszystkich pracowników marketingu i działu IT, którzy nie mają na imię John.

db.employees.find({
  department: { $in: ['IT', 'Marketing'] },
  firstName: { $ne: 'John' }
});
Ćwiczenie 2

Znajdź wszystkich pracowników działu IT z kolekcji employees.

db.employees.find({
  department: { $eq: 'IT' }
});

lub

db.employees.find({
  department: 'IT'
});
Ćwiczenie 3

Znajdź wszystkie projekty (products), w których klient jest kimś innym niż MyMusicWave Corp..

db.products.find({
  client: { $ne: 'MyMusicWave Corp.' }
});
Więcej warunków

Czy warunki mogą być jeszcze bardziej szczegółowe? Na przykład, gdybyśmy szukali osób w wieku z przedziału 18-40, lub chcieli sprawdzić pracowników, którzy np. zarabiają od 2000 do 4000 i od 6000 do 8000? Możemy to zrobić w MongoDB i wygląda to bardzo podobnie jak w zwykłych pętlach warunkowych. W takiej sytuacji używamy po prostu operatorów koniunkcji (&& – tutaj $and) oraz alternatywy (|| – tutaj $or).

Podobnie jak przy wcześniejszych instrukcjach, użycie $and oraz $or jest bardzo intuicyjne.

Składnia operatora $and wygląda tak:

{ $and: [<array-of-conditions>] }

W miejscu <array-of-conditions> możemy wstawiać tablicę tylu warunków, ilu tylko chcemy.

Na przykład wyszukiwanie osób, które mają więcej niż 18 lat, a mniej niż 40, mogłoby wyglądać następująco:

db.persons.find({ $and: [{ age: { $gt: 18 } }, { age: {  $lt: 40 }}] });

Taki zapis możemy rozumieć jak rozkaz: znajdź osoby, których wiek (age) spełnia dwa warunki – jest większy niż 18 i mniejszy niż 40. W zwykłej pętli warunkowej wyglądałoby to mniej więcej tak: if(age > 18 && age < 40).

Zapis bez $and

Warto dodać, że często istnieje również możliwość bezpośredniego użycia kilku operatorów dla danej właściwości, co dałoby nam taki sam efekt:

db.persons.find({ age: { $gt: 18, $lt: 40 }});

Jednak $and ma jedną ważną zaletę, której taki zapis nam nie daje – możemy kilkukrotnie warunkować jakiś jeden atrybut. Na przykład taki kod:

db.persons.find({ name: { $ne: 'John' }, name: { $ne: 'Amanda' }});

sprawi, że dla MongoDB istotny będzie tylko ostatni warunek, a więc znajdziemy wszystkie osoby, których imię jest inne niż Amanda. Natomiast

db.persons.find({ $and: [{ name: { $ne: 'John' }}, { name: { $ne: 'Amanda' }}] });

będzie pilnować, aby szukało nam osób, które mają imię inne od Johna, ale i Amandy.

Często $and używa się dla samej czytelności przekazu, ale warto stosować go przede wszystkim wtedy, kiedy właśnie mamy taką sytuację jak wyżej.

Ćwiczenie

W ramach ćwiczenia zajmijmy się taką przykładową kolekcją danych:

db.persons

[
  { name: 'John Doe', age: 20, salary: 5000 },
  { name: 'Amanda Doe', age: 20, salary: 3000 },
  { name: 'Thomas Jefferson', age: 50, salary: 3000 },
  { name: 'William Haze', age: 18, salary: 2000 }
]

Zastanów się, jak wyglądałaby instrukcja, która znajdowałaby wszystkie osoby będące w przedziale wiekowym od 20 do 50 lat i zarabiające od 2500 do 4000.

db.persons.find({
  $and: [{
    age: { $gt: 20, $lt: 50 }
  },
  {
    salary: { $gt: 2500, $lt: 4000 }
  }]
});

Całkiem logiczne, prawda? ;)

Podobnie możemy wykorzystywać $or.

{ $or: [<array-of-conditions>] }

$or to oczywiście alternatywa, więc wystarczy, aby tylko jedna opcja pasowała.

Przykładowo wyszukiwanie osób, które zarabiają mniej niż 2500 lub więcej niż 4000, mogłoby wyglądać tak:

db.persons.find({
  $or: [
    { salary: { $lt: 2500 } }, { salary: { $gt: 4000 } }
  ]
});

Możemy to rozumieć jako rozkaz: znajdź takie osoby, których pensja (salary) jest mniejsza od 2500 albo większa od 4000.

Ćwiczenie

Spróbuj zastanowić się, jak wyglądałaby analogiczna instrukcja dla tej samej kolekcji co w poprzednim ćwiczeniu (db.persons), która znajdowałaby wszystkie osoby będące w przedziale wiekowym powyżej 45 lub poniżej 20 lat i zarabiające od 1000 do 3000.

db.persons.find({
  $or: [
    { age: { $lt: 20 } }, { age: { $gt: 45 } }
  ],
  $and: [
    { salary: { $gt: 1000 } },
    { salary: { $lt: 3000 } }
  ]
});

Podobnie jak w samym JS-ie możemy ze sobą łączyć $and i $or, aby stworzyć bardziej zaawansowane warunki.

Na przykład:

db.persons.find({
  $or: [
    { $and: [
      { age: { $gt: 20 } },
      { age: { $lt: 50 } }
    ]},
    { age: { $eq: 18 } }
  ]
});

Ten kod wyszukałby osoby, których wiek mieści się w przedziale 20-50, albo mają równo 18 lat.

Możemy również używać $and i $or szerzej, na przykład:

db.persons.find({
  $or: [
    {
      age: 18,
      salary: { $gt: 3000 }
    },
    {
      age: { $gt: 25 }
    }
  ]
});

Powyższy kod wyszukiwałby osoby, które albo mają 18 lat i zarabiają więcej niż 3000, albo mają więcej niż 25 lat.

Oczywiście tego typu przykłady, mimo że wciąż w miarę intuicyjne, mogą być dla Ciebie zbyt skomplikowane. Nie obawiaj się, nieczęsto będziemy potrzebować aż tak dokładnych warunków, aby wybrać potrzebne dane. Chcieliśmy Ci po prostu pokazać jakie możliwości drzemią w metodach find i findOne. Przyznaj, że takie wybieranie jest o wiele lepsze niż "przechodzenie" po zwykłej tablicy za pomocą filter czy forEach, co robiliśmy w czystym JS-ie.

Update

Czas na aktualizację danych. MongoDB oferuje tutaj dwie metody:

  • updateMany – służy do aktualizacji wielu dokumentów,
  • updateOne – służy do aktualizacji tylko jednego pasującego dokumentu.

Pierwsza (updateMany) ma za zadanie znaleźć wszystkie elementy pasujące do warunku, który ustalimy i zmodyfikować je zgodnie z naszym życzeniem. Może to być jeden pasujący element, dwa, albo nawet wszystkie z danej kolekcji.

Druga (updateOne) szuka tylko jednego elementu pasującego do założonego warunku i tylko jego aktualizuje. Jeśli dokumentów pasujących jest więcej, to metoda ta zmodyfikuje po prostu pierwszy, który znajdzie. Jeśli żaden dokument nie pasuje, to zwyczajnie nic nie zostanie zrobione.

Ich składnia, podobnie jak w przypadku wcześniejszych metod, jest bardzo intuicyjna. Wygląda to następująco:

db.<collection-name>.updateMany(<condition>, <data-to-update>)
db.<collection-name>.updateOne(<condition>, <data-to-update>)

Pierwszy parametr będzie wyglądał tak samo, jak w przypadku find. Możemy więc ustalać w nim, że szukamy dokumentów, których np. name to John ({ name: 'John' }), albo wiek jest większy od 18 { age: { $gt: 18 }}. Możemy używać prostych warunków lub korzystać z wszelkich znanych Ci już operatorów. To po prostu parametr, który mówi, jakich elementów szukamy.

Drugi parametr ma za to ustalić, w jaki sposób chcemy zmodyfikować te dokumenty. Przyjmuje on następującą składnię:

{ $set: { <attrs-to-update>} }

Jest to obiekt z atrybutem o nazwie $set. Wartością tego atrybutu powinien być nowy obiekt, który będzie wskazywać jakie właściwości pasujących dokumentów powinny być zmodyfikowane (nie musimy bowiem aktualizować koniecznie całego dokumentu) i jaka ma być ich nowa wartość.

Spójrzmy na przykład:

db.persons.updateMany({ salary: { $gt: 3000 }}, { $set: { salary: 2500 }});

Powyższy kod znalazłby wszystkie osoby zarabiające powyżej 3000 i zmniejszył ich pensję do 2500. Taka instrukcja brzmiałaby bowiem jak rozkaz: znajdź wszystkie elementy, których atrybut salary ma wartość większą od 3000 i zmniejsz ich atrybut salary do 2500.

Oczywiście nie musimy modyfikować akurat tego atrybutu, po którym wyszukiwaliśmy pasujące rekordy.

Na przykład:

db.persons.update({ age: { $gt: 18 }}, { $set: { salary: 5000 }});

Tutaj szukalibyśmy pierwszej osoby z kolekcji, która ma więcej niż 18 lat i ustawialibyśmy jej atrybut salary (a więc pensję) na 5000.

Naturalnie liczba atrybutów, po których szukamy dokumentów, może być dowolna, podobnie zresztą jak w find. Dowolna może być również liczba modyfikacji, które chcemy do nich wprowadzić.

W tym momencie może pojawić się jeszcze jedno pytanie – a co jeśli w $set wstawilibyśmy atrybut, który w dokumencie nie istnieje? Cóż, znasz już trochę MongoDB, więc możesz się domyślać. W takiej sytuacji silnik zwyczajnie dodałby nowy atrybut do dokumentu/dokumentów.

Ćwiczenie

W ramach ćwiczenia przeszukamy taką przykładową kolekcję danych:

db.persons

[
  { name: 'John Doe', age: 20, salary: 5000 },
  { name: 'Amanda Doe', age: 20, salary: 3000 },
  { name: 'Thomas Jefferson', age: 50, salary: 3000 },
  { name: 'William Haze', age: 18, salary: 2000 }
]

Zastanów się, jak wyglądałaby instrukcja, która znajdowałaby wszystkie osoby będące w przedziale wiekowym od 18 do 30 lat i zarabiające od 2500 do 4000, a następnie zmieniała ich pensje na sztywne 3000.

db.persons.updateMany({
    $and: [
      { age: { $gt: 18, $lt: 30 } },
      { salary: { $gt: 2500, $lt: 4000 } }
    ]
  },
  {
    $set: { salary: 3000 }
});

Remove

Pozostała nam ostatnia funkcjonalność do omówienia – usuwanie danych. MongoDB oferuje nam tutaj dwie możliwości:

  • deleteOne – służy do usuwania jednego dokumentu,
  • deleteMany - służy do usuwania wielu dokumentów.

Składnia nie będzie tutaj dla Ciebie żadną nowością:

db.<collection-name>.deleteOne(<condition>)
db.<collection-name>.deleteMany(<condition>)

W miejscu <condition> wstawiamy po prostu warunek, po którym chcemy szukać pasujących dokumentów. Ponownie, wygląda to dokładnie tak samo, jak w find i pierwszym parametrze updateOne/updateMany. Może to być prosty warunek (np. { age: 18 }) albo bardziej skomplikowany z operatorami. Na dobrą sprawę, składnia jest wręcz identyczna jak find, z tym, że find wyszukuje pasujące elementy i tylko je zwraca, a tutaj nie tylko ich szukamy, ale również usuwamy.

Zauważ, że podobny warunek możemy zapisać także bez $and. Na przykład:

db.persons.deleteMany({ age: { $gt: 18, $lt: 30 } });

Powyższy kod znalazłby i usunął wszystkie osoby w przedziale wiekowym 18-30.

Drugi przykład:

db.persons.deleteOne({ age: 18 });

Tutaj usunęlibyśmy pierwszą osobę z kolekcji, której wiek byłby równy 18.

Podsumowanie

Zanim poznaliśmy MongoDB, mógł Ci się jawić jako kolejne skomplikowane narzędzie, które pewnie wspomoże pracę nad dużymi zbiorami danych, ale też dołoży sporą dawkę wiedzy do przyswojenia.

Mamy nadzieję, że teraz jego odbiór jest już trochę inny.

Mimo nowej wiedzy i potrzeby zapamiętania kilku metod nie można odmówić MongoDB intuicyjności. Bardzo podobny sposób przechowywania danych do tego, który znamy już z JS-a oraz prosta konstrukcja metod powoduje, że z MongoDB pracuje się podobnie jak przy użyciu zwykłych tablic, a może nawet prościej.

Oczywiście trochę nauki jeszcze przed nami. Musimy dowiedzieć się, jak korzystać z MongoDB w aplikacjach Node.js, poznamy też Mongoose i jej ideę modeli danych. Początek sugeruje jednak, że nie będzie to aż tak trudne, jak mogło się wydawać.

Zarówno MongoDB, jak i Mongoose, to bardzo intuicyjne technologie, które po tym module z pewnością umieścisz w swoim programistycznym arsenale.

29.2. MongoDB w Node.js

Operowanie na danych z bazy przy użyciu konsoli było stosunkowo proste, wiemy jednak, że nie jest to technika, z której będziemy korzystać na co dzień.

W tym submodule zajmiemy się komunikacją serwera Node.js z bazą danych MongoDB.

Postaramy się pracować od razu na praktycznym przykładzie, a będzie to serwer API dla aplikacji do zarządzania firmą, przy wykorzystaniu bazy danych CompanyDB, którą przygotowaliśmy w pierwszym submodule. Co ważne, zajmiemy się tylko i wyłącznie warstwą serwera, a więc stworzeniem dobrych endpointów i połączeniem z bazą danych. Warstwa klienta tym razem nas nie interesuje.

Pierwsze kroki

Zacznijmy od założeń. Nasza aplikacja powinna obsługiwać REST-owe endpointy dla trzech kolekcji danych:

  • employees
  • departments
  • products

Od razu mamy dla Ciebie niespodziankę. Serwer API jest już gotowy i możesz go pobrać pod tym linkiem.

Jeśli zajrzysz w kod, to nie ma tu dla Ciebie niczego nowego. Bardzo podobne, a wręcz identyczne serwery API, pisaliśmy już na etapie modułu o Expressie. Nasz gotowiec poprawnie obsługuje wszystkie endpointy, lecz ma jedną wadę... dane przechowuje w zwykłych tablicach. Zadanie będzie więc bardzo krótkie. Musimy tak zmodyfikować tę aplikację (serwer API), aby zamiast zwykłych tablic, korzystała z zewnętrznej bazy danych MongoDB.

Do dzieła

Zacznij od rozpakowania gotowca i odpalenia komendy yarn install. Chcemy bowiem, aby Yarn ściągnął wszystkie potrzebne zależności.

Oprócz paczek zapisanych w package.json będziemy musieli pobrać jeszcze jedną – mongodb. Jest to oficjalny pakiet twórców MongoDB, który – jak sama nazwa sugeruje – będzie służył do łatwej komunikacji z bazami danych Mongo.

yarn add mongodb@3.3.2

Następnie zaimportuj tę paczkę w pliku server.js:

const mongoClient = require('mongodb').MongoClient;

Zauważ, że potrzebujemy tylko części modułu mongodb, a mianowicie mongoClient, dającej dostęp do operacji CRUD. Podobnie robiliśmy, pracując z WebSocketami. Oczywiście, jeśli się uprzemy, możemy załadować po prostu cały moduł, tylko po co? ;)

Pozostaje nam teraz wykorzystać tę paczkę do utworzenia połączenia. MongoDB oferuje tutaj gotową funkcję connect:

mongoClient.connect('mongodb://localhost:27017', { useNewUrlParser: true, useUnifiedTopology: true }, (err, client) => {
  if (err){
    console.log(err);
  }
  else {
    console.log('Successfully connected to the database');
  }
});

Pierwszy parametr tej funkcji wskazuje na adres serwera MongoDB (u nas to lokalne localhost:27017), a drugi umożliwia opcjonalną konfigurację. Ustawienia, które wybraliśmy nie są konieczne do działania, ale pozwalają na wykorzystywanie nowszych możliwości silnika i twórcy zalecają ich stosowanie. Nie będziemy tutaj wchodzić w szczegóły. Trzeci parametr to po prostu funkcja callback, która ma uruchomić się już po próbie połączenia. Oczywiście połączenie to może się czasem nie udać. W takiej sytuacji treść błędu będzie dostępna w pierwszym parametrze funkcji callback (u nas to err). Jeśli jednak wszystko pójdzie dobrze, to obiekt klienta MongoDB zostanie zapisany w drugim parametrze (u nas client).

Wewnątrz funkcji callback możemy robić już co tylko chcemy, zarówno z bazą, jak i ewentualnym powiadomieniem o błędzie. Na razie sprawdzamy tylko, czy komunikat o niepowodzeniu istnieje. Jeśli tak, to pokazujemy go w konsoli, a jeśli nie, wypisujemy Successfully connected to the database.

Dodaj powyższy kod do swojej aplikacji i zobacz, czy po uruchomieniu serwera pokaże się poprawny komunikat. Jeśli dostaniesz informację o błędzie, to sprawdź, czy aby na pewno proces mongod jest uruchomiony.

Opakowujemy serwer w funkcję callback

Zwróć uwagę, że tak naprawdę serwer Express w tej chwili kompletnie nie wykorzystuje nowego kodu. Co więcej, samo połączenie z bazą danych jest asynchroniczne, więc zwyczajnie funkcja callback wykona się niezależnie od reszty skryptu. Obiekt client jest dostępny tylko w callbacku, a w reszcie kodu, gdzie inicjujemy też serwer, już nie. Tak naprawdę, serwer nie korzysta z naszej bazy, ale nawet nie ma takiej możliwości. Co z tym zrobić?

Najprostszym rozwiązaniem jest umieszczenie całego kodu inicjacji serwera Express w funkcji callback. Da nam to gwarancję, że zostanie on uruchomiony dopiero, gdy połączenie z bazą będzie gotowe. Uzyskamy też pewność, że serwer będzie miał w niej dostęp do obiektu client.

const express = require('express');
const cors = require('cors');
const mongoClient = require('mongodb').MongoClient;

const employeesRoutes = require('./routes/employees.routes');
const departmentsRoutes = require('./routes/departments.routes');
const productsRoutes = require('./routes/products.routes');

mongoClient.connect('mongodb://localhost:27017/', { useNewUrlParser: true, useUnifiedTopology: true }, (err, client) => {
  if(err) {
    console.log(err);
  }
  else {
    console.log('Successfully connected to the database');
    const app = express();

    app.use(cors());
    app.use(express.json());
    app.use(express.urlencoded({ extended: false }));

    app.use('/api', employeesRoutes);
    app.use('/api', departmentsRoutes);
    app.use('/api', productsRoutes);

    app.use((req, res) => {
      res.status(404).send({ message: 'Not found...' });
    })

    app.listen('8000', () => {
      console.log('Server is running on port: 8000');
    });
  }
});

Od tej chwili nasz server.js nie tylko łączy się z bazą, ale też pozwala na dostęp do niej z poziomu serwera Express, poprzez obiekt db.

Praktyczne przykłady

Czas na sprawdzenie, czy faktycznie dostęp do bazy jest taki, jakiego oczekiwaliśmy. Działanie na danych nie będzie różniło się bardzo od tego, co znasz już z konsoli i przykładów z pierwszego submodułu.

Wybieranie bazy danych

Zacznijmy od wybrania bazy danych, na której chcemy pracować, bo jak zapewne pamiętasz, MongoDB może utrzymywać ich wiele na swoim serwerze. W terminalu wystarczyło wpisać po prostu use companyDB, ale nie jest to format zapisu, który kiedykolwiek widywaliśmy w JS-ie, prawda? ;)

Tutaj prezentuje się to następująco:

const db = client.db('companyDB');

Wygląda trochę inaczej, ale równie intuicyjnie. Efekt jest dokładnie taki sam – otrzymujemy dostęp do wybranej bazy danych, a referencję do niej przechowujemy w stałej db.

Zapis const db = client.db('companyDB'); możemy rozumieć więc jako rozkaz: na serwerze MongoDB znajdź bazę danych o nazwie companyDB i przypisz referencję do niej do stałej db.

Wpisz powyższy kod na samym początku bloku else w funkcji callback metody connect.

else {
  console.log('Successfully connected to the database');
  const db = client.db('companyDB');
  ...

Teraz czas na operacje CRUD. Tutaj też zapis będzie bardzo podobny, choć wybranie kolekcji wygląda nieco inaczej. Zamiast db.<collection-name>.<method> (np. db.employees.find()) będziemy używali zapisu db.collection(<collection-name>).<method> (np. db.collection('employees').find()).

Sama składnia metod CRUD-owych będzie identyczna. Na przykład, wyszukiwanie osób poniżej wieku 18 lat wciąż mogłoby wyglądać tak – db.collection('persons').find({ age: { $lt: 18 } }). Składnia w find nie różni się niczym od tego, co znasz z przykładów testowanych w poprzednim submodule w terminalu.

Dobrze, przejdźmy do praktyki. Spróbujmy wykonać w pliku server.js kilka prostych operacji na bazie danych. Oczywiście wszystkie muszą być wywoływane wewnątrz funkcji callback naszego połączenia z bazą, bo tylko tam mamy do niej dostęp pod stałą db.

Wyszukiwanie danych

Spróbujmy teraz pobrać wszystkich pracowników (kolekcja employees), którzy pracują w dziale IT. Mogłoby to wyglądać następująco:

db.collection('employees').find({ department: 'IT' }, (err, data) => {
  //do something with data
});

Początek jest faktycznie bardzo podobny, ale mamy też jedną nowość – funkcję callback jako drugi parametr. Ma to swoje uzasadnienie. Przy użyciu konsolowego skryptu, wszystkie wyniki były wypisywane od razu w terminalu, co mogło być dla nas dość wygodne. W przypadku aplikacji Node.js chcielibyśmy jednak sami decydować, co zrobimy z wynikiem lub błędem. Możemy ustalić – pokażemy pobrane dane w podstawowej formie, czy jednak chcemy nad nimi jeszcze popracować. Wykorzystanie callbacków daje nam taką szansę.

Jak zapewne się domyślasz, pierwszy parametr (err) przechowywałby ewentualny błąd, a drugi (data) po prostu dane, które udało się odnaleźć. Tutaj pojawia się pytanie – czym jest właściwie data? Z terminalu pamiętamy, że find zwracało coś, co wyglądało jak zwykła tablica. Spróbujmy więc na razie wykorzystać zwykły console.log, który powinien być w stanie pokazać jej zawartość.

db.collection('employees').find({ department: 'IT' }, (err, data) => {
  if(!err) console.log(data);
});

Sprawdź, co otrzymasz teraz w konsoli.

Jak widzisz, nie jest to tablica... tylko Cursor.

image

Czym jest Cursor?

Kolekcje mogą być gigantyczne, a przesyłanie danych w formie ogromnych tablic byłoby niepraktyczne, zwłaszcza że często działamy tylko na ich części. Dlatego też zamiast całego zbioru, MongoDB zwraca nam coś w rodzaju wskaźnika do pierwszego elementu z grupy wszystkich pasujących. Co ważne, z tego pierwszego elementu łatwo możemy przejść do kolejnych. To daje możliwość iteracji jak w zwykłych tablicach i pozwala na wydajniejsze działanie na zbiorach.

Nie będziemy tu wchodzić w szczegóły. Jeśli interesuje Cię dokładne działanie Cursora, znajdziesz o nim wiele informacji w sieci. Wystarczy jednak, że zapamiętasz, iż to mechanizm, który ma pozwolić na wydajne pobieranie danych, przy czym w ostateczności pozwala nawet na zwykłe iterowanie, jak w każdej tablicy, mimo, że tak naprawdę sam nie jest tablicą.

Powiedzieliśmy, że find zwraca nam Cursor i możemy po nim iterować. Tylko jak? Nie użyjemy oczywiście żadnej z wbudowanych metod tyczących się tablic (jak np. forEach), bo w końcu Cursor != tablica. Możemy jednak użyć metody wbudowanej w sam Cursor. Jest nią each, a jej działanie przedstawia się analogicznie do tablicowych forEach.

Jak to wygląda w praktyce?

db.collection('employees').find({ department: 'IT' }, (err, data) => {
  if(!err) {
    data.each((error, employee) => {
      console.log(employee);
    })
  }
});

Drugą opcją jest po prostu przekonwertowanie danych do tablicy za pomocą toArray.

db.collection('employees').find({ department: 'IT' }).toArray((err, data) => {
  if(!err) {
    console.log(data)
  }
});

Znacząco poprawia nam to czytelność. Pracując na zwykłej tablicy, możemy np. korzystać z wbudowanych metod, jak forEach czy filter. Niestety tracimy w ten sposób trochę z wydajności, którą zaoferowałby Cursor, bo od razu ładujemy cały zestaw danych. Jeśli jednak wiesz, że i tak będziesz potrzebować wszystkich informacji, spokojnie możesz pójść tą drogą.

W naszym przykładzie zastosuj dowolną opcję z podanych wyżej. Sprawdź, czy w konsoli rzeczywiście pojawią się poprawne dane.

Co do findOne, to używalibyśmy jej analogicznie do find, tylko że data byłoby od razu jednym znalezionym elementem (lub nullem, jeśli nic nie znajdziemy).

db.collection('employees').findOne({ department: 'IT' }, (err, data) => {
  if(!err) {
    console.log(data)
  }
});

Dodawanie danych

Tutaj także zapis będzie analogiczny do tego, który znasz już z konsoli Mongo Shell.

Na przykład taki kod dodałby do kolekcji departments nowy element { name: 'Management' }:

db.collection('departments').insertOne({ name: 'Management' });

Podobnie jak w przypadku wyszukiwania danych, możemy tutaj użyć jeszcze funkcji callback, która wykonywałaby się po zakończeniu próby dodania elementu.

db.collection('departments').insertOne({ name: 'Management' }, err => {
  if(err) console.log('err');
});

Teraz, gdyby pojawił się jakiś problem, zostaniemy poinformowani o nim w konsoli.

Spróbuj wkleić ten kod do naszego przykładu i uruchom serwer. Pojawia się jednak pytanie – nawet jeśli w konsoli nie wyświetli się żaden błąd, to czy mamy pewność, że wszystko poszło dobrze?

Najlepiej w takich sytuacjach sprawdzić po prostu, co aktualnie znajduje się w bazie danych i czy faktycznie pojawił się w niej np. nowy element w kolekcji departments. Możemy to zrobić w konsoli Mongo Shell (wiesz już, że nie jest to zbyt wygodne), albo w jakimś graficznym programie (np. we wspomnianym wcześniej MongoDB Compass, czy Robomongo). Z konsoli nie będziemy już raczej korzystać, bo taka praca jest mało wydajna. Warto za to zapoznać się z jakimś graficznym programem, który w roli "kontrolera" mógłby pokazywać nam, czy aplikacja faktycznie dobrze modyfikuje bazę danych. Z dwóch wspomnianych programów trochę większe możliwości ma Robo 3T (Robomongo), niemniej jednak nam wystarczy nawet prostsza alternatywa – już zainstalowany MongoDB Compass.

MongoDB Compass

Nazwa tego programu dość dobrze opisuje jego rolę. Ma on być tylko kompasem, który wskaże poprawność działania naszej aplikacji i pozwoli lepiej zrozumieć, gdzie mamy szukać ewentualnych błędów. Jego możliwości nie są bardzo zaawansowane, ale w pełni wystarczające – potrafi on bowiem łączyć się z serwerem MongoDB, pokazywać listę kolekcji, baz danych i ich zawartość, zarządzać nimi (dodawać, usuwać, edytować) oraz importować dane do kolekcji z plików zewnętrznych, jak np. JSON. Idealnie spełni się więc w swojej roli.

Uruchom teraz MongoDB Compass.

Na stronie startowej otworzy się formularz do połączenia z serwerem bazy danych.

image

Nas interesuje przede wszystkim "Hostname" i "Port", gdzie możemy wskazać adres dostępu do serwera bazy danych. W naszej sytuacji wykorzystujemy domyślny localhost:27017, więc nie musimy nic wpisywać. Pozostałe opcje nie są dla nas teraz istotne, kliknij więc przycisk "Connect".

Po chwili program powinien pokazać listę wszystkich lokalnych baz danych:

image

Oczywiście już wiemy, że nasza jest tylko jedna – companyDB, a reszta to po prostu bazy wykorzystywane przez MongoDB.

Przy okazji, w tym miejscu możemy też bardzo łatwo dodać nową bazę danych. Wystarczy kliknąć w opcję "Create Database".

Z listy wybierz teraz companyDB.

image

MongoDB wskaże Ci listę kolekcji z wybranej bazy. Możemy usunąć dowolną z nich (ikonka kosza), dodać nową ("Create Collection"), albo sprawdzić jej zawartość. Zajrzyjmy do departments, bo chcemy się upewnić, czy nasza aplikacja dodała do tej kolekcji nowy element ({ name: 'Management' }).

image

Okazało się, że nasz kod w aplikacji faktycznie działa i możemy dodawać w ten sposób elementy do kolekcji.

Z poziomu tej zakładki możemy też usuwać dokumenty, modyfikować je, czy dodawać nowe. Operacje te są dość intuicyjne, nieczęsto jednak będziemy z nich korzystać. Głównie zależy nam, by wykorzystywać ten program właśnie do tego, co teraz – aby upewnić się, czy dana aplikacja serwera działa poprawnie i dobrze modyfikuje bazę danych.

Praktyczne przykłady cd.

Wróćmy do naszej aplikacji. Wykorzystaliśmy już w praktyce find i insertOne. Czas na kolejne przykłady.

Modyfikacja elementów

W przypadku modyfikacji danych sytuacja jest bardzo podobna jak przy ich dodawaniu. Możemy użyć prostej komendy (niczym w konsoli Mongo Shell), np.:

db.collection('employees').updateOne({ department: 'IT' }, { $set: { salary: 6000 }});

Możemy też dołożyć funkcję callback, jako kolejny parametr, który pozwoli nam wychwycić ewentualny błąd, np.:

db.collection('employees').updateOne({ department: 'IT' }, { $set: { salary: 6000 }}, err => {
  if(err) console.log(err);
});

Usuwanie elementów

Usuwanie będzie również bardzo podobne. Na przykład:

db.collection('departments').deleteOne({ name: 'Management' });

Analogicznie do wcześniejszych przykładów, możemy też dołożyć – jako kolejny parametr – funkcję callback:

db.collection('departments').deleteOne({ name: 'Management' }, (err) => {
  if(err) console.log(err);
});

Rozwijamy aplikację

Wiemy już mniej więcej jak wygląda współpraca MongoDB z serwerem Node.js. Omówiliśmy kilka przykładów, udało nam się połączyć z bazą na serwerze, wykonaliśmy nawet małą zmianę w kolekcji employees. Nasz cel jest jednak inny – chcemy zmodyfikować wszystkie endpointy, które oferuje serwer, tak aby zamiast zwykłej tablicy, korzystały z naszej bazy danych companyDB.

Wydaje się, że zadanie jest stosunkowo proste, musimy tylko lekko zmodyfikować każdy z endpointów. Na starcie pojawia się jednak pewien problem. Nasza stała db z dostępem do bazy danych jest dostępna tylko w pliku server.js, a same endpointy są przecież wydzielone do zewnętrznych plików. Jak możemy to obejść?

Identyczny problem pojawił się już przy module z WebSocketami i obiekcie io. Wtedy też inicjowaliśmy go w server.js, a chcieliśmy go potem wykorzystywać w zewnętrznych plikach z endpointami. Pamiętasz, jak wtedy to rozwiązaliśmy?

Wyglądało to następująco:

app.use((req, res, next) => {
  req.io = io;
  next();
});

W pliku server.js dodaliśmy przed wszystkimi endpointami nowy middleware i "przypięliśmy" obiekt io do req. Dzięki temu był on potem dostępny w każdym endpoincie, nawet w tych umieszczonych w zewnętrznych plikach, wystarczyło bowiem odwołać się do req.io.

Dokładnie to samo zrobimy z naszą bazą danych – referencję do obiektu db przypniemy do req. Teraz każdy endpoint będzie mógł łatwo odwołać się do bazy danych poprzez req.db, nawet jeśli będzie znajdował się w innym pliku niż server.js.

Dodaj więc następujący kod przed middleware ładującym zewnętrzne endpointy:

app.use((req, res, next) => {
  req.db = db;
  next();
});

Od tej chwili będziemy mieli dostęp do obiektu db w każdym endpoincie.

Na końcu usuń jeszcze kod, wyszukujący dane w employees i dodający nowy obiekt do departments. Był to w końcu tylko kod testowy, na którym uczyliśmy się podstaw działania MongoDB w Node.js.

Od tej chwili funkcja callback w connect powinna wyglądać tak:

(err, client) => {
  if(err) {
    console.log(err);
  }
  else {
    console.log('Successfully connected to the database');

    const db = client.db('companyDB');
    const app = express();

    app.use(cors());
    app.use(express.json());
    app.use(express.urlencoded({ extended: false }));

    app.use((req, res, next) => {
      req.db = db;
      next();
    });

    app.use('/api', employeesRoutes);
    app.use('/api', departmentsRoutes);
    app.use('/api', productsRoutes);

    app.use((req, res) => {
      res.status(404).send({ message: 'Not found...' });
    });

    app.listen('8000', () => {
      console.log('Server is running on port: 8000');
    });
  }
}

Modyfikujemy pierwszy endpoint

Czas w końcu na poważnie zmodyfikować aplikację. Już chwilę pracujemy nad naszym serwerem API, a tak naprawdę on wciąż działa na zwykłej tablicy. Czas to zmienić.

Zacznijmy od endpointów w departments.routes.js, a konkretnie od pierwszego z nich:

router.get('/departments', (req, res) => {
  res.json(db.departments);
});

Jak na razie zwraca on po prostu zawartość tablicy departments z obiektu db, który jest bazą danych tylko z nazwy. Jeśli zajrzysz do pliku, z którego go importujemy, przekonasz się, że to zwykły obiekt z kilkoma właściwościami. Musimy tak zmodyfikować ten endpoint, żeby zamiast danych z db.departments zwracał wszystkie dokumenty z kolekcji departments naszej bazy danych.

Zakładając, że w pliku departments.routes.js każdy endpoint ma dostęp do bazy poprzez req.db, modyfikacja powinna być stosunkowo łatwa do wykonania:

router.get('/departments', (req, res) => {
  req.db.collection('departments').find().toArray((err, data) => {
    if(err) res.status(500).json({ message: err });
    else res.json(data);
  });
});

Początek jest prosty. Odwołujemy się do naszej bazy danych (przez req.body), następnie wyszukujemy bezwarunkowo wszystkie dokumenty z kolekcji departments, a na końcu konwertujemy wynik do tablicy. Dlaczego? Ponieważ efekt ma być taki sam jak wcześniej – wciąż chcemy, żeby ten endpoint zwracał tablicę danych (a nie Cursor). Po prostu powinien pobierać ją teraz z bazy danych.

if(err) res.status(500).json({ message: err });
else res.json(data);

Powyższy kod sprawdza, czy nie ma czasem jakiegoś błędu – jeśli tak, to zwracamy go wraz z kodem statusu informującym o problemie (500). Gdy wszystko jest w porządku, zwracamy po prostu znalezione dane.

Jeśli wszystko poszło dobrze, to endpoint /api/departments pod metodą GET wciąż powinien zwracać tablicę z dobrymi danymi, tylko że teraz są one już pobierane z bazy danych.

Kolejne endpointy

Kolejny endpoint w kolejce to taki, który ma zwracać losowy element z kolekcji. Jeszcze nie mówiliśmy, jak możemy wprowadzić to w MongoDB, więc zostawimy go sobie na koniec. Teraz zajmiemy się tymi teoretycznie prostszymi.

GET /departments/:id
router.get('/departments/:id', (req, res) => {
  res.json(db.departments.find(item => item.id == req.params.id));
});

możemy przerobić na:

router.get('/departments/:id', (req, res) => {
  req.db.collection('departments').findOne({ _id: ObjectId(req.params.id) }, (err, data) => {
    if(err) res.status(500).json({ message: err });
    else if(!data) res.status(404).json({ message: 'Not found' });
    else res.json(data);
  });
});

Tym razem .toArray nie jest nam potrzebne, findOne zwróci bowiem od razu tylko jeden obiekt (dokument) lub null.

else if(!data) sprawdza, czy w ogóle coś znaleziono. Jeśli żaden dokument nie będzie pasować do warunku, MongoDB nie zwróci błędu, tylko null. Wysłanie w takiej sytuacji komunikatu Not found będzie bardziej eleganckim rozwiązaniem.

Dlaczego korzystamy z _id, a nie id? Przypomnij sobie. Gdy wprowadzaliśmy nasze dane do companyDB, żaden z elementów nie otrzymywał identyfikatora, bowiem MongoDB sam dodaje do każdego dokumentu losowe id (właśnie pod atrybutem _id).

Jednak czym jest ObjectId i do czego służy? Nadawane przez MongoDB _id nie jest stringiem, jednak req.params.id już tak. Pojawia się więc problem, bo nawet jeśli ciąg znaków w jednym i drugim będzie identyczny, to dla MongoDB pozostaną różne i dokument nie zostanie zwrócony. Pamiętasz, jak mówiliśmy, że === sprawdza typ i wartość, więc np. 5 === '5' daje nam false? Tak samo możemy tłumaczyć sobie tę sytuację. Wartość może wydawać się taka sama, ale jednak typ jest inny. Należy więc skonwertować req.params.id do typu ObjectId właśnie za pomocą funkcji ObjectId. Aby jej użyć, musimy ją jeszcze zaimportować. Dodaj więc na górze pliku:

const ObjectId = require('mongodb').ObjectId;

Powyższy kod po prostu zaimportuje tę metodę z naszej pobranej już wcześniej biblioteki mongodb.

POST /departments

Przyszedł czas na Ciebie. Wzorując się na dwóch poprawionych endpointach, postaraj się modyfikować kolejne już bez naszej pomocy. Jeśli nie czujesz się jeszcze na siłach, pod każdym przykładem możesz zajrzeć do gotowego kodu.

W przypadku problemów z naszym wyzwaniem nie przejmuj się. To jeszcze nie jest moment, w którym "musisz" dać sobie radę z takim zadaniem. Niemniej warto spróbować swoich sił.

Zacznijmy od POST /departments.

router.post('/departments', (req, res) => {
  const { name } = req.body;
  db.departments.push({ id: 3, name })
  res.json({ message: 'OK' });
});

Powyższy kod możemy przerobić na:

router.post('/departments', (req, res) => {
  const { name } = req.body;
  req.db.collection('departments').insertOne({ name: name }, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  })
});

Aby sprawdzić, czy endpoint na pewno działa poprawnie, po zasymulowania requestu w Postmanie, możesz skorzystać z Compassu i przejrzeć zmienianą kolekcję.

PUT /departments/:id
router.put('/departments/:id', (req, res) => {
  const { name } = req.body;
  db = db.departments.map(item => (item.id == req.params.id) ? { ...item, name } : item );
  res.json({ message: 'OK' });
});

Ten fragment mógłby wyglądać tak:

router.put('/departments/:id', (req, res) => {
  const { name } = req.body;
  req.db.collection('departments').updateOne({ _id: ObjectId(req.params.id) }, { $set: { name: name }}, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  });
});

Powyższy kod jest wystarczający. Moglibyśmy jeszcze przed aktualizacją sprawdzić za pomocą find czy element w ogóle istnieje i w razie problemu po prostu zwrócić komunikat Not found. Nie jest to jednak wymagane. W końcu mamy tylko zmodyfikować nasz serwer API, a nie zmieniać jego funkcjonalności.

DELETE /departments/:id
router.delete('/departments/:id', (req, res) => {
  db = db.departments.filter(item => item.id != req.params.id)
  res.json({ message: 'OK' });
});

Możemy przerobić to na:

router.delete('/departments/:id', (req, res) => {
  req.db.collection('departments').deleteOne({ _id: ObjectId(req.params.id) }, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  });
});

Losowanie dokumentu

Pozostaje nam endpoint, który wcześniej ominęliśmy, czyli:

router.get('/departments/random', (req, res) => {
  res.json(db.departments[Math.floor(Math.random() * db.length)]);
});

Powinien on losować jeden dokument z kolekcji departments. Nie jest to wielkie wyzwanie, ale jeszcze nie wspominaliśmy jak tego dokonać.

Otóż możemy tutaj wykorzystać funkcję aggregate wraz z opcją $sample.

Używa się jej następująco:

db.collection(<collection-name>).aggregate([{ $sample: { size: <documents-amount> } }])
)

Zobaczmy taki przykład:

db.collection('employees').aggregate([ { $sample: { size: 3 } } ]);

Ten kod zwróci nam trzy losowe dokumenty z kolekcji employees.

Uwaga!

aggregate służy generalnie do pobierania "próbek" całych kolekcji. Można wybrać ich rozmiar, sposób grupowania itd. Dużą zaletą jest też to, że próbki są randomowe. Tym samym ustawiając jednoelementową próbę $sample, otrzymamy pojedynczy losowy element.

Jeśli chcesz wiedzieć o niej więcej, zajrzyj do dokumentacji.

Dobrze, ale jak będzie wyglądał w takim razie nasz endpoint po zmianach?

router.get('/departments/random', (req, res) => {
  req.db.collection('departments').aggregate([ { $sample: { size: 1 } } ]).toArray((err, data) => {
    if(err) res.status(500).json({ message: err });
    else res.json(data[0]);
  });
});

Po co nam toArray? aggregate nawet przy pobieraniu jednego elementu zwraca zbiór danych. To nic nowego – podobny mechanizm znamy np. z podstaw JSa i querySelectorAll, który zawsze zwracał NodeList, nawet jeśli tylko jeden element pasował do selektora. Teraz mamy dokładnie taką samą ideę – aggregate może zwrócić zbiór jedno, dwu, lub stuelementowy, ale zawsze będzie to zbiór danych, a nie pojedynczy dokument.

Usuwamy zbędny import

Gotowe! Wystarczy teraz usunąć zbędny import db, bo już nie korzystamy z tego obiektu.

Kod naszego pliku departments.routes.js powinien wyglądać mniej więcej tak:

const express = require('express');
const router = express.Router();
const ObjectId = require('mongodb').ObjectId;

router.get('/departments', (req, res) => {
  req.db.collection('departments').find().toArray((err, data) => {
    if(err) res.status(500).json({ message: err });
    else res.json(data);
  });
});

router.get('/departments/random', (req, res) => {
  req.db.collection('departments').aggregate([ { $sample: { size: 1 } } ]).toArray((err, data) => {
    if(err) res.status(500).json({ message: err });
    else res.json(data[0]);
  });
});

router.get('/departments/:id', (req, res) => {
  req.db.collection('departments').findOne({ _id: ObjectId(req.params.id) }, (err, data) => {
    if(err) res.status(500).json({ message: err });
    else if(!data) res.status(404).json({ message: 'Not found' });
    else res.json(data);
  });
});

router.post('/departments', (req, res) => {
  const { name } = req.body;
  req.db.collection('departments').insertOne({ name: name }, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  })
});

router.put('/departments/:id', (req, res) => {
  const { name } = req.body;
  req.db.collection('departments').updateOne({ _id: ObjectId(req.params.id) }, { $set: { name: name }}, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  });
});

router.delete('/departments/:id', (req, res) => {
  req.db.collection('departments').deleteOne({ _id: ObjectId(req.params.id) }, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  });
});

module.exports = router;

Testujemy endpointy

Na etapie modyfikacji endpointów nie wymagaliśmy od Ciebie ich testowania. Kod, który Ci na końcu zaprezentowaliśmy, powinien być poprawny. Często jednak będziesz go pisać bez naszej pomocy, a wtedy warto sprawdzić, czy wszystko działa. W module o Expressie do testowania endpointów używaliśmy Postmana, wciąż będziemy z niego korzystać, tylko że teraz dodatkowo możemy sprawdzać na bieżąco efekty zmian, które wprowadza serwer. Jest to możliwe dzięki podglądowi bazy danych w MongoDB Compass. Możesz z niego korzystać, aby upewnić się, czy dane na pewno dobrze się modyfikują.

Jeśli boisz się, że "popsujesz" jakieś dane, to proponujemy Ci testować endpointy na dokumencie { name: 'Management' }. Możemy zdradzić już teraz, że nie będzie on istotny w kolejnych modułach. Nic nie stoi na przeszkodzie, by go nawet usunąć z kolekcji.

Podsumowanie

Korzystanie z biblioteki mongodb na serwerze może zostawiać po sobie słodko-gorzki posmak. Z jednej strony samo utworzenie połączenia jest bardzo proste, a z metod korzysta się identycznie jak w terminalu, co jest dość wygodne. Z drugiej strony można odnieść wrażenie, że co chwila pojawia się jakieś "ale". Połączenie z bazą jest proste, ale... musisz przenieść inicjację serwera do funkcji callback metody connect. Dostęp do bazy jest w obiekcie db, ale... żeby mieć do niego wgląd w zewnętrznych plikach, trzeba dodać middleware. Możemy wyszukiwać dokumenty po automatycznie nadawanym _id, ale... musimy konwertować przyrównywaną wartość do typu ObjectId, co wymaga od nas importu specjalnej metody z paczki.

To prawda, pakiet mongodb jest stosunkowo surowy. Jego zadanie polega przede wszystkim na oferowaniu dostępu do bazy danych, a nie funkcji "pomocniczych", które będą dbały o nasz komfort pracy. Warto wiedzieć, że na rynku istnieją gotowe paczki, które są o wiele wygodniejsze w działaniu, a również pozwalają na pracę z MongoDB. Jedną z nich jest wcześniej wspomniana biblioteka Mongoose, którą poznamy w kolejnej części.

Zadanie: endpointy

Twoim zadaniem jest dokończenie modyfikacji naszej aplikacji. Do zmiany pozostają dwa pliki employees.routes.js i products.routes.js. Przebuduj je analogicznie do departments.routes.js.

Po zakończeniu pracy usuń zbędny plik db.js z głównego katalogu. Następnie stwórz repozytorium zdalne na GitHub, wyślij do niego swoje pliki, a link podaj Mentorowi.

29.3. Mongoose – pierwsze starcie

Poprzedni submoduł uświadomił Ci, że prostota paczki mongodb wymaga od nas dodatkowej pracy. W tym momencie z pomocą przyjdzie nam Mongoose, czyli biblioteka, która opakowuje możliwości mongodb w bardziej przyjazny interfejs.

Dzięki tej paczce nasz kod będzie znacznie krótszy i czytelniejszy, a dane lepiej zorganizowane. Po kilku projektach, zwłaszcza bardziej skomplikowanych, na pewno to docenisz.

Skoro to wszystko brzmi tak pięknie, dlaczego od razu nie skorzystaliśmy z tej paczki? Otóż ważne jest, by poznać i zrozumieć działanie MongoDB, oraz sposób komunikacji z bazą, zanim przejdziemy dalej i zaczniemy używać innych narzędzi.

Mongoose ułatwia wykonywanie podstawowych operacji, ale też dodaje funkcjonalność, której w samym MongoDB brakuje, a więc modelowanie danych. W skrócie polega to na tym, że każda kolekcja będzie musiała mieć z góry ustalony schemat dokumentów – jakie właściwości powinny mieć dane w konkretnej kolekcji oraz jakie są ich typy. Narzuca to na MongoDB pewne ograniczenia (celowo) i wymusza walidację poprawności danych. Tym samym sposób komunikacji z bazą będzie znacząco różnił się od tego, co już znamy. Dlatego też rozpoczęcie pracy z aplikacjami Node.js + MongoDB od razu z Mongoose, mogłoby okazać się rzuceniem Cię na zbyt głęboką wodę. Często towarzyszyłoby Ci bowiem wrażenie, że "przecież to kompletnie coś innego niż w terminalu". Teraz jednak, gdy wiesz już, jak działa najprostsze komunikowanie się z bazą z poziomu Node.js, wejście w Mongoose powinno być trochę łatwiejsze i na pewno docenisz komfort pracy, który nam zapewni.

Kod zmieni się, ale "pod maską" Mongoose nadal będzie wykorzystywać dokładnie te same proste komendy, które znamy już z poprzedniego submodułu.

Wciąż będziemy pracować na naszym serwerze API. Wcześniej zajmowaliśmy się modyfikacją endpointów, by używały bazy danych zamiast zwykłej tablicy. Teraz wprowadzimy kolejne zmiany i zaczniemy korzystanie z dobrodziejstw Mongoose. Pozwoli to na zwiększenie czytelności aplikacji, ale też dodanie modelowania danych, co naprawdę jest bardzo przydatną funkcjonalnością (powiemy o tym więcej za chwilę).

Instalacja Mongoose

Dość gadania, czas na praktykę. Mamy dużo to omówienia :)

Zacznijmy od pobrania paczki:

yarn add mongoose@5.7.1

Modyfikacja server.js

Czas na modyfikację server.js. Zacznij od importu:

const mongoose = require('mongoose');

Następnie usuń odwołanie do mongodb, bo choć jest potrzebny, Mongoose importuje go "pod maską" i nie musimy tego robić jeszcze raz. Podobnie działał Express – wykorzystywał wbudowany w Node.js moduł http, ale importował go sam.

Czas na modyfikację połączenia z bazą danych. Tutaj zmieni się najwięcej. Pamiętasz, jak narzekaliśmy na to, że inicjacja serwera musiała być umieszczana w funkcji callback połączenia? W Mongoose nie ma takiej potrzeby i wygląda to o wiele prościej:

// connects our backend code with the database
mongoose.connect('mongodb://localhost:27017/companyDB', { useNewUrlParser: true, useUnifiedTopology: true });
const db = mongoose.connection;

Wyjaśnijmy, co się tu dzieje.

mongoose.connect('mongodb://localhost:27017/companyDB', { useNewUrlParser: true, useUnifiedTopology: true });

Pierwsza linijka otwiera połączenie z serwerem bazy danych (mongodb://localhost:27017/) i przypisuje go do obiektu mongoose.connection, który jest od razu dostępny wszędzie, a nie tylko w funkcji callback, jak to miało miejsce w paczce mongodb. Zresztą, tutaj funkcji callback nie ma w ogóle, co sprawia, że nasz kod jest jeszcze bardziej czytelny. Dodatkowo zauważ, że tym razem nie łączymy się z serwerem bazy danych, aby dopiero potem wybrać konkretną bazę. Zamiast tego, wybór bazy możemy określić od razu w adresie (mongodb://localhost:27017/companyDB). Wygodne prawda?

const db = mongoose.connection;

Druga linijka to już nic specjalnego. Skracamy sobie dostęp do naszej bazy danych, przypisując referencję do stałej db.

No dobrze, ale teraz skąd mamy wiedzieć, czy połączenie się udało? W callbacku connect w paczce mongodb mieliśmy dostęp do ewentualnego błędu i łatwo mogliśmy ustalić, czy po drodze na pewno wszystko poszło, jak trzeba. W Mongoose będzie to wyglądało inaczej, choć prawdopodobnie nawet lepiej.

Mongoose wykorzystuje w swojej pracy technikę emitowania zdarzeń (eventów), pozwala również na dodawanie nasłuchiwaczy do obiektu połączenia (mongoose.connection). Idea eventów, emiterów i nasłuchiwaczy nie jest Ci obca. Poniższy kod powinien być więc zrozumiały, nawet bez dłuższego komentarza z naszej strony.

db.once('open', () => {
  console.log('Connected to the database');
});
db.on('error', err => console.log('Error ' + err));

Co robimy w tym miejscu?

db.once('open', () => {
  console.log('Connected to the database');
});

Ustawiamy nasłuchiwacz na nasze połączenie i mówimy, że kiedy JS wykryje zdarzenie open, to w konsoli powinien wypisać komunikat Connected to the database. Zdarzenie jest emitowane w przypadku sukcesu połączenia, mamy więc pewność, że kiedy nasłuchiwacz je wykryje, to na pewno wszystko się udało. Dziwna może być tylko nazwa metody, której użyliśmy do przypięcia nasłuchiwacza, a mianowicie once. W nasłuchiwaczu niżej widzimy przecież metodę on i taka nazwa zastosowana przez twórców wydaje się bardziej sensowna. once, jak sama nazwa wskazuje, ma nam po prostu sugerować, że to wyjątkowy nasłuchiwacz, taki, który dane zdarzenie otrzyma tylko raz i potem nie ma już sensu na niego oczekiwać. Oczywiście użycie on (w Mongoose to już normalny nasłuchiwacz) również by zadziałało, ale zwyczajnie nie ma sensu nasłuchiwać ciągle na zdarzenie, które na pewno wykona się co najwyżej raz.

db.on('error', err => console.log('Error ' + err));

Drugi nasłuchiwacz jest już znacznie prostszy w odbiorze. Gdy Mongoose natrafi na błąd, to emituje zdarzenie error i wysyła z nim także komunikat o błędzie. Gdy wykryjemy takie zdarzenie, po prostu pokażemy błąd w konsoli. Tym razem użyliśmy on, a nie once, bo error może emitować się wiele razy, nie tylko przy połączeniu, ale zawsze, gdy coś pójdzie nie tak.

Jak powinien wyglądać nasz server.js po zmianach?

const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');

const employeesRoutes = require('./routes/employees.routes');
const departmentsRoutes = require('./routes/departments.routes');
const productsRoutes = require('./routes/products.routes');

const app = express();

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use('/api', employeesRoutes);
app.use('/api', departmentsRoutes);
app.use('/api', productsRoutes);

app.use((req, res) => {
  res.status(404).send({ message: 'Not found...' });
})

// connects our backend code with the database
mongoose.connect('mongodb://localhost:27017/companyDB', { useNewUrlParser: true });
const db = mongoose.connection;

db.once('open', () => {
  console.log('Connected to the database');
});
db.on('error', err => console.log('Error ' + err));

app.listen('8000', () => {
  console.log('Server is running on port: 8000');
});

Trochę czytelniej, prawda?

Przy okazji, zapewne udało Ci się zauważyć, że zniknął również nasz middleware z poprzedniego submodułu. Już nam się nie przyda, bo Mongoose będzie dostępna w plikach zewnętrznych w inny, przystępniejszy sposób.

Inaczej, znaczy lepiej

Kod pisany w Mongoose odchodzi trochę od tego, co pamiętamy z bezpośredniej modyfikacji bazy danych za pomocą terminala.

Paczka mongodb była w tym względzie bardziej konserwatywna, bo np. jeśli w terminalu najpierw łączymy się z serwerem bazy danych (włączając Mongo Shell), a dopiero potem wybieramy konkretną bazę – tak samo robimy to w mongodb.

Mongoose nie ma problemu z innym podejściem, o ile zwiększy się nasz komfort pracy. Ma to odzwierciedlenie w połączeniu z bazą danych (gdzie od razu wybieramy konkretną), ale jeszcze bardziej widoczne stanie się to później, przy modyfikacjach kolekcji.

Taki jest zamysł Mongoose – ma ona maksymalnie ułatwiać pracę z MongoDB, często proponując nawet kompletnie inną składnię.

Modelowanie danych

Przebudowaliśmy już server.js, więc czas na modyfikację plików z endpointami. Zanim jednak to zrobimy, opowiedzmy trochę o modelowaniu danych, bowiem w Mongoose każda kolekcja musi posiadać własny model z opisanym schematem struktury danych. Bez modelu, a więc i bez schematu, nie możemy nic z nimi zrobić.

Po co w ogóle to "modelowanie"?

MongoDB domyślnie nie blokuje nas żadnymi ograniczeniami podczas dodawania danych, zatem do jednej kolekcji możemy wrzucać różne dane, nawet kompletnie inne.

Na przykład, gdybyśmy w ramach testu wykonali następujące instrukcje w terminalu Mongo Shell:

db.departments.insertOne({ depart: 'abc' });
db.departments.insertOne({ name: 1, rating: 2, employeees: 5 });

to nie napotkalibyśmy ze strony MongoDB żadnego oporu. Nasza kolekcja wyglądałby teraz tak:

[
  { _id: '423534346546457fs4tt5', name: 'IT' },
  { _id: '423534rw3246457fs4tt5', name: 'Marketing' },
  { _id: '434534346546457fs4tt5', name: 'Testing' }
  { _id: '143534346546457fs4tt5', depart: 'abc' },
  { _id: '4332fdgd4335457fs4tt5', name: 1, rating: 2, employees: 5 }
]

Spójrz tylko. Niektóre dokumenty zawierają jeden atrybut poza _id, a inne dwa, czasami mają name, a czasami nie, ponadto raz name jest tekstem, a innym razem liczbą. Nie ma tu żadnej spójności danych.

Z jednej strony możemy zrozumieć twórców MongoDB. Kolekcje miały jak najbardziej przypominać zwykłe tablice w JS-ie, a te przecież nie ograniczają nas w dodawaniu informacji. Niemniej jednak w przypadku baz danych, wolelibyśmy, aby każdy dokument miał taki sam schemat.

Gdyby np. nasza kolekcja departments miała być pokazywana w aplikacji klienta jako zwykła lista z nazwami, to pragnęlibyśmy założyć z góry, że konkretny atrybut (np. name), da nam zawsze właśnie nazwę. Nie chcielibyśmy, żeby ta informacja raz była dostępna pod atrybutem name, a innym razem np. pod depart. Pisanie kodu obsługującego takie dane byłoby przecież straszną katorgą.

Jednak czy to naprawdę konieczne? Możesz powiedzieć, że rozumiesz problem, ale nie widzisz potrzeby stosowania modeli, bo przecież sami możemy się pilnować, aby dodawać poprawne dane. Robiliśmy tak dotychczas i nawet nieźle nam to wychodziło.

To prawda, moglibyśmy, ale przyznaj, że byłoby lepiej, gdyby jednak ktoś pilnował nas z boku. Przecież zawsze istnieje możliwość przypadkowej pomyłki – możemy zrobić literówkę w name albo nasza aplikacja źle obsłuży jakiś formularz i zamiast wartości tekstowej zapisze w bazie undefined. Takie sytuacje mogą się zdarzyć, a MongoDB przyjąłby wadliwe dane.

Potrzebę istnienia modeli w bazie danych możemy tłumaczyć tak samo jak przydatność propTypes w komponentach reactowych. Niby nie musimy z nich korzystać, ale jednak warto je mieć, bo nawet jeśli coś pomylimy, szybko się o tym dowiemy.

Wymuszanie modelowania przez Mongoose powinniśmy więc odczytywać tylko jako plus.

Pierwszy schemat danych

Zanim przejdziemy do modyfikacji samych endpointów, musimy przygotować modele. Zaczniemy od tego dla kolekcji departments.

Załóż folder models, a następnie stwórz w nim department.model.js. Co prawda, wcale nie musimy umieszczać modeli w osobnych plikach i indywidualnym katalogu, ale jest to dobra praktyka, która pozwoli nam utrzymać porządek.

Prosta analogia

Aby uświadomić Ci, jak prosta jest idea modeli w Mongoose, wrócimy na chwilę do czystego JS-a. Wcześniej mówiliśmy, że kolekcje w MongoDB nie sprawdzają spójności danych, bo nie robią tego też zwykłe tablice w JS-ie, a na nich się wzorowano. Być może pamiętasz jednak, że czasami udawało nam się taką spójność osiągać – dokonywaliśmy tego za pomocą klas.

Na przykład w poniższym kodzie łatwo moglibyśmy wprowadzić do tablicy niespójne dane:

const persons = [];

persons.push({ firstName: 'John', lastName: 'Doe' });
persons.push({ firstNam: 'Amanda', lastName: 'Doe' });

Spójrz tylko. Zrobiliśmy małą literówkę przy pushowaniu drugiego obiektu. Czy to spowoduje jakiś błąd? Nie, do tablicy zostanie wprowadzony wadliwy obiekt.

Wystarczy jednak użyć klasy:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

const persons = [];
persons.push(new Person('John', 'Doe'));
persons.push(new Person('Amanda', 'Doe'));

Od teraz istnieje gwarancja, że oba obiekty będą miały dokładnie te same atrybuty. Oczywiście wciąż możemy się pomylić w samej informacji (wartości) i zrobić np. literówkę, ale obiekty będą spójne pod względem struktury.

Dokładnie taką ideę wykorzystuje w funkcjonalności modelowania również Mongoose. Wprowadza jednak nieco bardziej zaawansowany zamysł schematów struktury danych. Będziemy w nim bowiem mogli, a nawet musieli, ustalać nie tylko ilość i nazwę atrybutów, ale też ich typ. Wszystko to sprowadza się do jednego – pilnowania poprawności wprowadzanych danych.

Bierzmy się do pracy. Wejdź do pliku department.model.js i zacznij od importu mongoose:

const mongoose = require('mongoose');

Co dalej? Czas na ustalenie schematu struktury danych. Do tego potrzebna będzie nam specjalna klasa Schema, którą dostarcza Mongoose. Na razie nie mówimy jeszcze o tworzeniu modelu, a przygotowujemy schemat struktury danych, który potem będziemy mogli wykorzystać w modelu dowolnej kolekcji. Składnia wygląda następująco:

new mongoose.Schema({
  attr: type,
  attr: type,
  attr: type,
  attr: type
});

Jeśli chcemy, aby dane atrybuty były wymagane w dokumencie, możemy to osiągnąć tak:

new mongoose.Schema({
  attr: { type, required },
  attr: { type, required },
  attr: { type, required },
  attr: { type, required }
});

Zatem w naszej kolekcji departments odpowiedni mógłby być następujący zapis:

const departmentSchema = new mongoose.Schema({
  _id: { type: mongoose.Types.ObjectId, required: true },
  name: { type: String, required: true }
});

W powyższym schemacie ustalamy, że dane powinny mieć dwa atrybuty. Oba są obowiązkowe (dba o to required: true). Pierwszy będzie nazywać się _id, a jego oczekiwany typ to ObjectId, drugi to name i ma mieć wartość tekstową. Całość może Ci przypominać obiekt konfiguracyjny propTypes z komponentów reactowych i faktycznie – idea jest podobna.

_id w schemacie

Jedynym atrybutem, którego nie musimy tak naprawdę sprawdzać jest _id. Wiemy, że jego wartość jest nadawana automatycznie przez MongoDB, a zatem powinna zawsze być poprawna. Dlatego też możemy go w ogóle pominąć w schemacie:

const departmentSchema = new mongoose.Schema({
  name: { type: String, required: true }
});

Wyjątkiem są sytuacje, w których z jakiegoś powodu, decydujemy się sami nadawać wartość _id. Wtedy warto sprawdzić, czy nasz kod zawsze wykonuje to poprawnie. My nie będziemy jednak tego robić w niniejszym module i zawsze oprzemy się na domyślnych zachowaniu MongoDB.

Oczywiście to na razie tylko schemat struktury danych i żeby Mongoose z niego korzystał, będziemy musieli go o tym powiadomić. Użyjemy do tego metody mongoose.model, która ustali, by dla konkretnej kolekcji używany był model danych z konkretnym schematem.

Zajmiemy się tym za chwilę, a teraz pomówmy jeszcze o samych typach.

Mongoose oferuje domyślnie wsparcie następujących typów:

  • String
  • Number
  • Date
  • Buffer
  • Boolean
  • Mixed
  • ObjectId
  • Array
  • Decimal128
  • Map

Mówiąc wsparcie, mamy na myśli to, że możemy wpisywać te typy w schemacie danych Mongoose, ale też to, że paczka będzie w stanie poprawnie zwalidować dokument w przypadku próby wprowadzania danych do bazy. Zamysł jest bowiem taki, że Mongoose sprawdzi dane pod kątem ilości i nazw atrybutów, ale walidowane będą też same wartości, czy ich typ jest zgodny z tym, co założyliśmy.

Nietypowe typy

Naturalnie w pracy będziemy wykorzystywać tylko niektóre typy z powyższej listy. Jeśli będziesz potrzebować innego, nieobsługiwanego przez Mongoose, istnieje możliwość zainstalowania dodatkowych pluginów. Możesz ich szukać pod tym linkiem.

Dla praktyki spróbuj zastanowić się, jak mógłby wyglądać schemat dla kolekcji employees, a następnie porównaj swój pomysł z naszym:

const employeesSchema = new mongoose.Schema({
  firstName: { type: String, required: true },
  lastName: { type: String, required: true },
  department: { type: String, required: true }
});

Tablica lub obiekt jako atrybut

MongoDB pozwala również na typowanie bardziej skomplikowanych struktur. Przydaje się to w sytuacji, kiedy atrybutem ma być np. kolejna tablica albo kolejny obiekt. Wygląda to wtedy następująco:

new mongoose.Schema({
  field1: {
    type: String
  },
  field2: {
    type : [{
      subField1: {
        type: String
      },
      subField2: {
        type: Number
      }
    }],
  }
});

Pierwszy model

Czas na utworzenie modelu na bazie naszego schematu. Jak mówiliśmy już wcześniej, skorzystamy tutaj z metody mongoose.model.

mongoose.model('Department', departmentSchema);

Pierwszy parametr mówi nam, jak będzie nazywał się nasz model, a drugi określa, z jakiego schematu struktury danych ma korzystać.

Gdy model jest gotowy, możemy go wyeksportować, aby można było go zaimportować i wykorzystać w dowolnym pliku.

Nasz plik department.model.js na końcu powinien wyglądać tak:

const mongoose = require('mongoose');

const departmentSchema = new mongoose.Schema({
  name: { type: String, required: true }
});

module.exports = mongoose.model('Department', departmentSchema);

Skąd Mongoose wie jakiej kolekcji tyczy się model?

Cóż, to jest akurat bardzo sprytne. Mongoose zakłada, że model tyczy się tej kolekcji, której nazwa jest równa nazwie modelu, ale pisanej z małych liter i zakończonej literą "s". Zatem jeśli nasz model nazywa się Department, to Mongoose powiąże go z kolekcją danych departments. Jeśli taka kolekcja jeszcze by nie istniała, to zostałaby automatycznie utworzona.

Analogicznie, gdybyśmy nazwali model Test, to Mongoose powiązałoby go z kolekcją tests.

Wykorzystujemy model w praktyce

Nasz model jest już gotowy, dzięki czemu Mongoose wie już jakie dane powinny być przechowywane w kolekcji departments. Możemy więc teraz zabrać się za modyfikację endpointów w departments.routes.js.

Zaczniemy, nie po kolei, od endpointu:

router.post('/departments', (req, res) => {
  const { name } = req.body;
  req.db.collection('departments').insertOne({ name: name }, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  })
});

Z użyciem Mongoose będzie on wyglądał następująco:

router.post('/departments', async (req, res) => {

  try {

    const { name } = req.body;
    const newDepartment = new Department({ name: name });
    await newDepartment.save();
    res.json({ message: 'OK' });

  } catch(err) {
    res.status(500).json({ message: err });
  }

});

Podmieńmy go.

Początek wydaje się dość ciekawy, ale też jasny.

const newDepartment = new Department({ name: name });

Możemy to zrozumieć jako rozkaz: utwórz nowy obiekt typu Department. Zauważ, że musimy przekazać tylko name, bo akurat _id będzie nadawane automatycznie.

Dziwić może jednak to:

newDepartment.save();

Jak to? Uruchamiamy jakąś metodę save i ona ma wystarczyć? A gdzie metoda insertOne?

Wyjaśnijmy sobie tutaj jedną rzecz. Model w Mongoose służy nam nie tylko do walidacji danych. Ta klasa ma również wiele wbudowanych metod, z których możemy bardzo łatwo korzystać. Np. save powinna po prostu spróbować dodać dokument do kolekcji zgodnej z modelem (u nas Department tyczy się kolekcji departments). Pod maską oczywiście, save korzysta ze zwykłego insertOne. Te gotowe metody mają nam po prostu pomóc w łatwiejszym pisaniu kodu i przyznaj, wygląda to obiecująco, prawda?

Podsumowując, nasz skrypt zadziała następująco:

  1. Najpierw "wyciągnie" parametr name z req.body i przypisze go do stałej.
  2. Potem utworzy nowy dokument na bazie modelu Department. Na tym etapie w bazie jeszcze go nie ma.
  3. Następnie zapisuje go w kolekcji (save = zapisz dokument do kolekcji).
  4. Na końcu oczekuje na wykonanie metody (await) i jeśli wszystko poszło dobrze, to zwraca komunikat OK.

Skoro korzystamy z async...await, jak pewnie pamiętasz, powinniśmy cały kod przechowywać w bloku try...catch, bowiem pozwala to na wyłapywanie ewentualnych błędów. Teraz też tak robimy i jeśli Mongoose zwróci jakiś błąd, nasz serwer przekaże go klientowi.

Oczywiście pamiętaj też, że ważny jest schemat struktury danych, z którego korzysta model. Musimy trzymać się właściwości i typów, które są w nim zapisane. Jeśli więc np. wymagamy w schemacie atrybutu name, to nie możemy zrobić czegoś takiego:

const newDepartment = new Department({ test: 2 });
await newDepartment.save();

Po pierwsze, name jest wymagany, a u nas go brakuje. Po drugie, wcale nie zezwalamy na test. Mongoose zwróciłby w takiej sytuacji błąd.

Promise vs Async/Await

Jeśli zamiast składni async...await wolisz promise'y, możesz zapisywać kod Mongoose również z ich pomocą.

Na przykład nasz endpoint mógłby wyglądać tak:

router.post('/departments', (req, res) => {

    const { name } = req.body;
    const newDepartment = new Department({ name });
    newDepartment.save()
      .then(() => {
        res.json({ message: 'OK' });
      })
      .catch(err => {
        res.status(500).json({ message: err });
      });

});

Wybór należy do Ciebie. Niemniej jednak my zachęcamy do korzystania z async...await, co przy bardziej skomplikowanych operacjach jest o wiele czytelniejsze niż zapis z promise'ami.

Oczywiście nie zapomnijmy jeszcze o jednej rzeczy. Jeśli chcemy korzystać z wybranego modelu, to musimy go zaimportować. Na górze pliku dodaj:

const Department = require('../models/department.model');

Teraz nasz endpoint jest już gotowy, ale na razie nie próbuj go jeszcze testować. Serwer w większości endpointów stara się korzystać z req.db (które już nie działa), więc na razie informowałby od razu o błędach. Sprawdzimy, czy wszystko funkcjonuje prawidłowo, ale dopiero wtedy, kiedy skończymy pracę nad całym plikiem.

Kolejne endpointy

Teraz będziemy starali się iść już zgodnie z kolejnością.

GET router.get('/departments')

Zaczniemy od endpointu pobierającego i zwracającego wszystkie dane z kolekcji.

W tej chwili wygląda on tak:

router.get('/departments', (req, res) => {
  req.db.collection('departments').find().toArray((err, data) => {
    if(err) res.status(500).json({ message: err });
    else res.json(data);
  });
});

Przy użyciu Mongoose będzie wyglądać tak:

router.get('/departments', async (req, res) => {
  try {
    res.json(await Department.find());
  }
  catch(err) {
    res.status(500).json({ message: err });
  }
});

Zauważ, że ponownie działamy na metodzie z klasy (modelu) Department. Wcześniej było to .save, teraz jest to find. Akurat ta nazwa może być dla Ciebie łatwiejsza do rozszyfrowania. Mongoose'owy find używa po prostu metody find z MongoDB. Warto zauważyć również, że w przypadku Mongoose nie musimy się już przejmować konwersją danych do tablicy. Paczka robi to za nas.

Oprócz find Mongoose oferuje również dwie kolejne metody pomocnicze do pobierania danych:

  • findById(id) – skrót do find({ _id: id }) z MongoDB,
  • findOne() - skrót do findOne z MongoDB.

W find możesz oczywiście wpisywać dowolny warunek oraz używać operatorów. Robimy to dokładnie tak samo, jak w poprzednim submodulem, a więc np. szukanie działów innych niż IT wyglądałoby tak – Department.find({ name: { $ne: 'IT' }}).

Podsumowując powyższy kod, tak jak wcześniej, powinien on znaleźć po prostu wszystkie dane z kolekcji departments i zwrócić je klientowi.

GET /departments/random

Tym razem nie będziemy zostawiać najtrudniejszego na koniec :)

W tej chwili ten endpoint wygląda następująco:

router.get('/departments/random', (req, res) => {
  req.db.collection('departments').aggregate([ { $sample: { size: 1 } } ]).toArray((err, data) => {
    if(err) res.status(500).json({ message: err });
    else res.json(data[0]);
  });
});

W Mongoose wciąż możemy używać funkcji aggregate, ale istnieje też przystępniejsza opcja z wykorzystaniem metody skip i countDocuments. Metoda skip pozwala na pominięcie przy wyszukiwaniu dowolnej ilości dokumentów, natomiast countDocuments potrafi po prostu zliczyć ilość wszystkich dokumentów w kolekcji.

Z tą wiedzą możemy zmodyfikować nasz endpoint następująco:

router.get('/departments/random', async (req, res) => {

  try {
    const count = await Department.countDocuments();
    const rand = Math.floor(Math.random() * count);
    const dep = await Department.findOne().skip(rand);
    if(!dep) res.status(404).json({ message: 'Not found' });
    else res.json(dep);
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

Jak możemy zrozumieć ten kod? Najpierw liczymy wszystkie elementy w kolekcji. Następnie losujemy liczbę, ale taką, która nie będzie większa od ilości dokumentów. W kolejnym kroku spróbujemy wybrać bezwarunkowo jeden element. Skoro nie mamy jednak warunku, to oczywiście Mongoose będzie zwracać pierwszy dokument z kolekcji, zawsze ten sam, a tego nie chcemy. Owszem, potrzebujemy jednego elementu, ale nie zawsze tego samego. I tutaj do gry wchodzi skip, która zapewnia nas, że wyszukiwanie będzie rozpoczynane z różnego miejsca. Jeśli rand równa się 1, to rozpoczynamy wyszukiwanie od pierwszego dokumentu. Ten od razu pasuje do warunku, więc zostanie zwrócony. Jeśli jednak rand równa się 100, to wyszukiwanie pasującego elementu zacznie się od dokumentu nr 100 i to on będzie zwrócony jako pierwszy pasujący. Na samym końcu upewniamy się już tylko, czy udało się cokolwiek znaleźć (w końcu kolekcja może być pusta) i zwracamy znaleziony element lub ewentualny błąd.

GET /departments/:id

Kolejny endpoint będzie już o wiele prostszy:

router.get('/departments/:id', (req, res) => {
  req.db.collection('departments').findOne({ _id: ObjectId(req.params.id) }, (err, data) => {
    if(err) res.status(500).json({ message: err });
    else if(!data) res.status(404).json({ message: 'Not found' });
    else res.json(data);
  });
});

Zamienimy go na:

router.get('/departments/:id', async (req, res) => {

  try {
    const dep = await Department.findById(req.params.id);
    if(!dep) res.status(404).json({ message: 'Not found' });
    else res.json(dep);
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

Uwaga!

Zauważ, że nie musimy tym razem korzystać z funkcji ObjectId do konwersji stringu req.params.id do odpowiedniego formatu. Mongoose zajmuje się tym za nas.

Na tym etapie warto również w ogóle usunąć zbędny import tej funkcji. Nie będziemy z niej korzystać.

PUT /departments/:id

Teraz w kolejce było dodawanie działu, ale ten endpoint zrobiliśmy już na samym początku. Od razu możemy przejść do następnego.

router.put('/departments/:id', (req, res) => {
  const { name } = req.body;
  req.db.collection('departments').updateOne({ _id: ObjectId(req.params.id) }, { $set: { name: name }}, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  });
});

Zmodyfikujemy go tak:

router.put('/departments/:id', async (req, res) => {
  const { name } = req.body;

  try {
    await Department.updateOne({ _id: req.params.id }, { $set: { name: name }});
    res.json({ message: 'OK' });
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

Jak zapewne się domyślasz, updateOne pod maską odpali po prostu updateOne w MongoDB. Składnia w samej metodzie jest identyczna. Oprócz tego Mongoose oferuje również updateMany do aktualizacji wielu elementów, która działa analogicznie do updateMany z czystego MongoDB. Efekt będzie taki sam, jak byśmy uruchomili te komendy w konsoli Mongo Shell.

Dokładniejsza walidacja

Nasz zmodyfikowany endpoint powinien działać poprawnie, ale wciąż nie jest idealny. Kod zakłada, że dokument do usunięcia na pewno istnieje. A co jeśli wpiszemy w linku błędne id? Jak wtedy nasz endpoint sobie z tym poradzi?

Nie musisz tego testować. Powiemy Ci – pokaże po prostu błąd 500, który informuje użytkownika o winie serwera. Czy to dobra praktyka? Często błąd wyrzucany przez Mongoose faktycznie będzie winą serwera, ale czy wpisanie złego id w adresie przez użytkownika również nim jest? Raczej nie. O wiele bardziej adekwatna byłaby tutaj informacja o błędzie 404.

Najłatwiej zmienić to następująco:

router.put('/departments/:id', async (req, res) => {
  const { name } = req.body;

  try {
    const dep = await(Department.findById(req.params.id));
    if(dep) {
      await Department.updateOne({ _id: req.params.id }, { $set: { name: name }});
      res.json({ message: 'OK' });
    }
    else res.status(404).json({ message: 'Not found...' });
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

Logiczny sposób? Oczywiście. Sprawdzamy, czy element istnieje i jeśli tak, to go aktualizujemy, a jeśli nie, zwracamy odpowiedni kod i komunikat.

Innym, zalecanym przez Mongoose, sposobem jest następująca technika:

router.put('/departments/:id', async (req, res) => {
  const { name } = req.body;

  try {
    const dep = await(Department.findById(req.params.id));
    if(dep) {
      dep.name = name;
      await dep.save();
      res.json({ message: 'OK' });
    }
    else res.status(404).json({ message: 'Not found...' });
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

Wykorzystuje ona metodę save. Zauważ, że gdy tworzyliśmy nowy dokument i w kolekcji jeszcze go nie było, save zwyczajnie go dodawało. Kiedy stosujemy tę metodę na istniejącym elemencie, Mongoose go aktualizuje. Cały kod możemy więc podsumować jako rozkaz: znajdź odpowiedni dział po id, zmień jego atrybut name na wartość z req.params.id i zaktualizuj ten dokument w kolekcji.

To trochę inne podejście, ale możesz się z nim często spotkać.

Spróbuj wykorzystać jedną z opcji w naszym endpoincie.

DELETE /departments/:id

Został nam już tylko jeden endpoint. Metody, które oferuje Mongoose do usuwania danych są bardzo intuicyjne, deleteOne to odpowiednik znanego Ci już deleteOne z Mongo Shell, podobnie sprawa ma się z deleteMany. Ich użycie będzie więc tak samo proste, jak updateOne w endpoincie powyżej.

Spróbuj zmodyfikować ostatni endpoint bez naszej pomocy:

router.delete('/departments/:id', (req, res) => {
  req.db.collection('departments').deleteOne({ _id: ObjectId(req.params.id) }, err => {
    if(err) res.status(500).json({ message: err });
    else res.json({ message: 'OK' });
  });
});

Następnie upewnij się, że wszystko gra.

router.delete('/departments/:id', async (req, res) => {

  try {
    const dep = await(Department.findById(req.params.id));
    if(dep) {
      await Department.deleteOne({ _id: req.params.id });
      res.json({ message: 'OK' });
    }
    else res.status(404).json({ message: 'Not found...' });
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

To tyle!

Metoda remove

Warto wiedzieć, że Mongoose udostępnia jeszcze jedną metodę do usuwania danych. Nazywa się ona remove i ma zastosowanie w przypadku bezpośredniego dostępu do dokumentu.

Na przykład, jeśli z jakiegoś powodu załadowalibyśmy już dane z bazy:

const department = await Department.find({ _id: '42423fwfe5435fsdf' });

i chcielibyśmy taki dokument usunąć, to zamiast korzystać z deleteOne:

const department = await Department.find({ _id: '42423fwfe5435fsdf' });
await Department.deleteOne({ _id: department._id });

moglibyśmy użyć metody remove bezpośrednio na tym dokumencie:

const department = await Department.find({ _id: '42423fwfe5435fsdf' });
await department.remove();

Oczywiście Mongoose wciąż korzystałby tutaj z deleteOne i efekt byłby dokładnie taki sam. To po prostu "skrót", który ma poprawić czytelność kodu. Jest to dokładnie taki sam zabieg, jak w przypadku metody save, która pozwalała nam na czytelniejsze dodawanie lub modyfikowanie danych.

Naturalnie ta metoda ma zastosowanie tylko wtedy, gdy dany dokument jest już załadowany, więc mamy do niego dostęp. W przeciwnym razie szybszym wyjściem będzie skorzystanie z deleteOne lub deleteMany.

Testujemy

Czas przetestować zmiany. W server.js zakomentuj na chwilę kod importujący i korzystający z dwóch pozostałych plików z route'ami. One wciąż bazują na req.db, więc uruchomienie wyrzucałoby nam błąd.

Włącz serwer i za pomocą Postmana oraz MongoDB Compass postaraj się ustalić, czy wszystkie endpointy działają poprawnie.

Podsumowanie

Tak naprawdę w kwestii modyfikacji bazy danych niczego nowego się nie dowiedzieliśmy – dodawać, usuwać czy aktualizować dane umieliśmy przecież już wcześniej. Poznanie Mongoose pozwala nam jednak znacznie uprościć te operacje, uporządkować kod, a do tego wprowadza niezwykle ważną i pomocną technikę modelowania danych, która w przypadku większych aplikacji jest nieodzowna.

Dzięki tym zaletom Mongoose prawdopodobnie na stałe wyląduje w gronie Twoich ulubionych narzędzi i będzie nieodzownym elementem backendowego stacku technologicznego.

Po tym submodule jesteś już w stanie utworzyć i obsługiwać bazy danych złożone z dowolnych kolekcji. Potrafisz dodawać nowe dokumenty, modyfikować je oraz usuwać.

Bazy danych to jednak często coś więcej niż tylko pojedyncze kolekcje. Tworzą bardziej skomplikowane struktury, a ich dane mogą się w jakiś sposób przenikać. Musimy je wtedy integrować albo odpowiednio filtrować, bo np. na jednej podstronie chcemy użyć danych z kolekcji departments i employees, albo potrzebujemy wybrać dane z jednej, sprawdzając, czy istnieje odpowiedni dokument w drugiej. Często mówimy w kontekście baz danych o relacjach i właśnie to, a więc relacyjność, będzie tematem naszego kolejnego submodułu.

Zadanie: trenujemy

Czas na dwa krótkie ćwiczenia.

Zadanie 1

Zadanie pierwsze nie jest zbyt wymagające i polega na modyfikacji pozostałych dwóch plików z route'ami, aby również korzystały z dobrodziejstw Mongoose. Możesz wzorować się na departments.routes.js. Nie zapomnij również o odpowiednich schematach struktury danych i modelach! Najlepiej zacznij właśnie od nich.

Uwaga!

Jeśli dla testów endpointów departments.routes.js importy pozostałych plików w server.js zostały zakomentowane, to nie zapomnij przywrócić ich do życia.

Zadanie 2

Zadanie drugie jest już trochę bardziej wymagające. W tej chwili, gdy edytujemy lub usuwamy element, serwer kwituje to wysłaniem komunikatu OK. Twoim zdaniem jest taka modyfikacja endpointów do usuwania i aktualizowania danych w pliku departments.routes.js, aby po udanej operacji zwracały jeszcze dokument, na którym pracowały.

Zatem, jeśli usuwasz z kolekcji dokument, to chcemy, aby serwer po udanej operacji usunięcia, zwrócił nam jeszcze ten dokument w response. Gdy modyfikujesz, to serwer po udanej operacji zmiany, również powinien zwracać ten zmieniony dokument w odpowiedzi.

Uwaga!

Zadanie wymaga modyfikacji tych dwóch endpointów tylko w departments.routes.js. W pozostałych plikach nie musisz już tego robić.

Do wykonania zadania możesz wykorzystać znane Ci już metody lub przejrzeć listę innych, których jeszcze nie opisywaliśmy. Znajdziesz ją pod tym linkiem.

29.4. Mongoose – relacje między kolekcjami

Kiedy mówimy o bazach danych, bardzo często możemy usłyszeć o relacyjności danych. Co to w ogóle znaczy?

Wyobraź sobie bazę danych z takimi dwoma kolekcjami:

// BooksDB

books: [
  {
    _id: 1,
    title: 'The Catcher in the Rye',
    author: {
      name: 'J. D. Salinger',
      nationality: 'USA',
      votes: 125
    }
  },
  {
    _id: 2,
    title: 'Of Mice and Men',
    author: {
      name: 'John Steinbeck' ,
      nationality: 'USA',
      votes: 89
    }
  }
]

authors: [
  {
    _id: 1,
    name: 'J. D. Salinger',
    nationality: 'USA',
    votes: 125
  },
  {
    _id: 2,
    name: 'John Steinbeck' ,
    nationality: 'USA',
    votes: 89
  }
]

Oprócz tego załóżmy, że dane te są wykorzystywane w trzech miejscach:

  • Strona główna – pokazuje tylko listę tytułów.
  • Podstrona "Autorzy" – pokazuje listę nazwisk autorów.
  • Podstrona "Książki" – pokazuje listę tytułów wraz z autorami i ich liczbą głosów.

Czy taka struktura kolekcji miałaby rację bytu? Wydaje się, że tak. Na stronie głównej wykorzystywalibyśmy dane z books, na podstronie z autorami authors, a na podstronie z książkami znowu books i tylko books, bo same informacje o autorach są zawarte również w tej kolekcji. Wygląda to na dość wygodne rozwiązanie, ale wcale nie jest idealne...

Czy udało Ci się zauważyć jakąś wadę w takiej konstrukcji struktury danych? Część danych się dubluje (chodzi o informacje o autorach), a to bardzo zła praktyka. Pamiętaj, że dane w kolekcjach nie powinny się powielać. To jedna z zasad, której warto trzymać się przy projektowaniu baz danych.

Dlaczego powielanie danych jest takie złe? W końcu samo pobieranie z takich kolekcji, jak już powiedzieliśmy, byłoby bardzo proste i wygodne.

Po pierwsze, niepotrzebnie zwiększamy rozmiar naszej bazy danych. To istotna kwestia, chociażby z tego względu, że często utrzymujemy je na serwerach zdalnych i płacimy za zajmowane miejsce.

Po drugie, trzymając te dane w kilku miejscach, znacznie utrudniamy sobie ich modyfikację. Powiedzmy, że chcesz zmienić np. ilość głosów dla jednego z autorów. Musielibyśmy wtedy zmodyfikować nie tylko dokument w authors, ale też jego odpowiednik w books, bo inaczej dane nie byłyby spójne.

W takim razie, jak moglibyśmy przerobić te dane? Najprostszy sposób to wydzielenie ich (autorzy sobie, książki sobie) i wykorzystywanie idei referencji. Czym może być taka referencja? Identyfikatorem, nazwą, czymkolwiek, co będzie w stanie wskazać na właściwy unikalny dokument (z innej kolekcji).

Na przykład:

// BooksDB

books: [
  {
    _id: 1,
    title: 'The Catcher in the Rye',
    authorId: 1
  },
  {
    _id: 2,
    title: 'Of Mice and Men',
    authorId: 2
  }
]

authors: [
  {
    _id: 1,
    name: 'J. D. Salinger',
    nationality: 'USA',
    votes: 125
  },
  {
    _id: 2,
    name: 'John Steinbeck' ,
    nationality: 'USA',
    votes: 89
  }
]

Zauważ, że teraz w kolekcji books nie trzymamy już zduplikowanych informacji o autorze. Mamy tylko atrybut, który wskazuje na id tego dokumentu w drugiej kolekcji – authors. Oczywiście, zamiast identyfikatora moglibyśmy tutaj użyć również nazwiska, ale jednak bezpieczniejszą praktyką jest zastosowanie _id, które zawsze jest unikalne.

Trochę teorii

Taki proces porządkowania danych, wydzielania kolekcji i niwelowania duplikatów (tzw. redundancji, nadmiarowości) nazywa się normalizacją bazy danych. Sama relacja, którą możemy teraz zauważyć pomiędzy authors a books – jest relacją jeden do wielu (wiele książek może mieć referencję do tego samego autora).

W odniesieniu do MongoDB rzadko korzystamy z nazewnictwa takich powiązań, bo sam silnik domyślnie w żaden sposób takich relacji nie zauważa, ani nic z nimi nie robi (sytuację poprawia Mongoose, ale tylko trochę). Ich używanie ma większy sens w przypadku baz relacyjnych, jak SQL czy MySQL, które rzeczywiście zauważają takie powiązania i pozwalają nawet na zabezpieczanie tego typu więzi (np. usunięcie autora musiałoby korelować z wymuszoną modyfikacją atrybutu authorId niektórych książek). Niemniej jednak, warto zapamiętać to nazewnictwo, bowiem osoby "wychowane" na klasycznych bazach danych bardzo często używają tych nazw również w kontekście MongoDB. Wypada więc wiedzieć, o czym mówią.

Oprócz relacji jeden do wielu możemy analogicznie mówić o relacjach wiele do wielu, wiele do jednego, czy jeden do jednego.

Jak działałby nasz kod w praktyce? Na głównej stronie ładowalibyśmy dane z kolekcji books (bo tu interesują nas tylko tytuły), a na podstronie z autorami wyciągalibyśmy jedynie dokumenty z authors. Gorzej sytuacja miałaby się już w przypadku kolejnej podstrony, na której potrzebujemy pokazywać zarówno tytuły książek, jak i nazwiska oraz oceny autorów. Musielibyśmy najpierw pobrać kolekcję książek, a potem, przed wyświetleniem informacji, znaleźć jeszcze dane autora w authors na bazie authorId.

Jak mogłoby to wyglądać? Na przykład tak:

const books = await Book.find();
if(books && books.length) {
  for(book of books) {
    const author = await Author.findById(book.authorId);
    if(author) {
      book.author = author;

      console.log('Book title', book.title);
      console.log('Book author', book.author.name);
      console.log('Book author votes', book.author.votes);
    }
  }
}

Taki kod zadziałałby następująco. Najpierw pobieralibyśmy wszystkie książki, a jak wiemy, nie mają one dokładnych informacji o autorze. Zamiast tego posiadają informację o id odpowiedniego dokumentu w kolekcji authors. Dlatego też, zanim pokażemy informacje o danej książce, musimy w jakiś sposób pobrać te dokładne informacje. Robimy to, ściągając po prostu cały dokument z kolekcji authors o _id równym book.authorId, a następnie przypisując go do obiektu książki jako nowy atrybut. Tym samym otrzymujemy na końcu obiekt book, który posiada dokładne informacje nie tylko o książce, ale również o autorze. Autor odpowiadający authorId jest zapisany po prostu jako nowy atrybut book.

Czy taki zapis jest dla nas wygodny? Nie do końca, na pewno wymaga trochę więcej pracy. Akurat pod tym względem stary układ był nieco lepszy. Musisz przyznać jednak, że znacznie poprawiliśmy jakość naszej przykładowej bazy danych. Informacje się nie duplikują, kolekcje są małe i przyjazne w odbiorze, chcąc modyfikować autora, wystarczy zrobić to w jednym miejscu, a jak widać załadowanie nawet bardzo dokładnych danych wciąż jest możliwe. Trudniejsze, ale możliwe.

Ćwiczenie

Co ciekawe, nasza baza danych companyDB też cierpi na redundancję (nadmiarowość) danych. Co prawda sytuacja jest mniej skomplikowana, ale jednak warto byłoby również to naprawić. Spójrz tylko na te dwie kolekcje:

// CompanyDB

departments: [
  { id: '3254353412frsdfs', name: 'IT' },
  { id: '3254354232frsdfs', name: 'Testing' },
  { id: '32543545436fgdds', name: 'Marketing' }
]

employees: [
  {
    _id: '5d89da449adfcce066c6b643',
    firstName: 'Amanda',
    lastName: 'Doe',
    department: 'Marketing'
  }
  ...
]

Atrybut department w employees to tak naprawdę zdublowana informacja z departments. Do tego, gdybyśmy chcieli zmienić np. nazwę któregoś z działu, konieczna byłaby modyfikacja nie tylko w kolekcji departments, ale i niektórych dokumentów z employees...

Jak widzisz, sytuacja w naszej bazie nie jest idealna. Twoim zadaniem będzie więc (wzorując się na przykładzie z Books) odpowiednie jej znormalizowanie. Kolekcje możesz zmodyfikować za pomocą konsoli lub w MongoDB Compass, technika jest dowolna.

Uwaga!

Modyfikując dane w Compassie możesz zastanawiać się, jaki typ danych należy wybrać dla nowych wartości department w dokumentach kolekcji employees. Domyślnie używaliśmy stringu, ale przecież teraz będzie to jakaś referencja. Czy należy więc użyć czegoś innego?

W założeniu department powinno być referencją po id z innej kolekcji, a to sugeruje, że należy użyć typu ObjectId. To prawda, ale Compass nie daje nam takiej możliwości... Spokojnie możesz jednak pozostawić po prostu String. Mongoose, jak przekonasz się później, poradzi sobie z tym i skonwertuje tę wartość w razie potrzeby.

Dążymy do następującej struktury danych:

departments: [
  { id: '3254353412frsdfs', name: 'IT' },
  { id: '3254354232frsdfs', name: 'Testing' },
  { id: '32543545436fgdds', name: 'Marketing' }
]

employees: [
  {
    _id: '5d89da449adfcce066c6b643',
    firstName: 'Amanda',
    lastName: 'Doe',
    department: '3254353412frsdfs'
  }
  ...
]

Chcemy, aby atrybut department w employees był tylko referencją po _id do odpowiedniego dokumentu w departments.

Wiemy już jak mniej więcej ma to wszystko wyglądać, ale pojawia się pytanie. Czy naprawdę musimy wybierać – poprawna struktura danych w bazie albo wygodne ich pobieranie? Nie, jeśli mamy odpowiednie pomocnicze metody. Tak się składa, że akurat Mongoose takowe posiada.

Metoda populate

W przykładzie powyżej pokazywaliśmy, jak możemy przygotować dane z jednej kolekcji, aby zawierała też coś z drugiej. Wykorzystywaliśmy przy tym zamysł referencji. Nie było to może bardzo trudne w realizacji, ale jednak wymagało od nas czasu i pozostawiło po sobie sporą ilość kodu. Warto wiedzieć jednak, że Mongoose posiada wbudowaną metodę, która może to robić za nas! Mowa o populate.

Aby jej użyć, Mongoose musi zdawać sobie sprawę, który atrybut w dokumencie jest w istocie referencją do innej kolekcji oraz do jakiej dokładnie. My bowiem wiedzieliśmy, że authorId tyczy się kolekcji authors tylko dlatego, że sami wymyśliliśmy taką relację. Osoba postronna mogłaby to zauważyć po nazewnictwie atrybutu, ale skąd ma o tym wiedzieć Mongoose?

Musimy ją poinformować o tym w schemacie struktury danych kolekcji.

Twój model Employee ma w tej chwili mniej więcej taką strukturę:

const employeeSchema = new mongoose.Schema({
  firstName: { type: String, required: true },
  lastName: { type: String, required: true },
  department: { type: String, required: true }
});

Musimy teraz dodatkowo poinformować Mongoose, że atrybut department ma być referencją do kolekcji Department. Możemy to zrobić, dodając opcję ref.

const employeeSchema = new mongoose.Schema({
  firstName: { type: String, required: true },
  lastName: { type: String, required: true },
  department: { type: String, required: true, ref: 'Department' }
});

Od teraz Mongoose już wie, że atrybut department to referencja do dokumentu w kolekcji departments (model Department korzysta bowiem właśnie z departments).

Jeśli mamy to gotowe, teraz wystarczy, że przy pobieraniu danych (find) wywołamy metodę populate, a Mongoose sam zajmie się "dobraniem" odpowiednich danych z konkretnej kolekcji.

Na przykład:

Employee.find();

zwróciłoby nam dane tylko z employees:

[
 {
   _id: '5d89da449adfcce066c6b643',
   firstName: 'Amanda',
   lastName: 'Doe',
   department: '3254353412frsdfs'
 }
 ...
]

Gdybyśmy użyli jeszcze metody populate:

Employee.find().populate('department');

to w miejsce department obiekty pracowników miałyby konkretną informację o dziale:

[
 {
   _id: '5d89da449adfcce066c6b643',
   firstName: 'Amanda',
   lastName: 'Doe',
   department: {
     id: '3254353412frsdfs',
     name: 'IT'
   }
 }
 ...
]

Krótko mówiąc, populate składa nasze dane z dwóch lub więcej kolekcji w jeden większy obiekt, tak jak robiliśmy to wcześniej sami. Jak widzisz, nie musimy iść na kompromis. Możemy mieć odpowiednią strukturę w bazie danych, a przy tym wciąż nie mieć problemu z ich pobieraniem.

populate('department');

Aby wszystko było jasne – parametr populate mówi po prostu, który atrybut Mongoose powinien zostać w taki sposób obsłużony.

Dla praktyki powiedzmy tylko jeszcze, jak mogłaby wyglądać funkcja do pobierania książek, gdybyśmy odpowiednio skorzystali z mongoose'owych schematów danych i populate.

Ten kod:

const books = await Book.find();
if(books && books.length) {
  for(book of books) {
    const author = await Author.findById(book.authorId);
    if(author) {
      book.author = author;

      console.log('Book title', book.title);
      console.log('Book author', book.author.name);
      console.log('Book author votes', book.author.votes);
    }
  }
}

skróciłby się do:

const books = await Book.find().populate();
if(books && books.length) {
  console.log('Book title', book.title);
  console.log('Book author', book.author.name);
  console.log('Book author votes', book.author.votes);
}

Podsumowanie

Oczywiście to tylko namiastka "relacyjności". W innych znanych silnikach (jak SQL czy MySQL) wygląda to o wiele bardziej zaawansowanie. Wbrew pozorom jednak, populate często będzie wystarczające, aby zadbać o jakość naszej bazy danych, a z racji tego, że to tak proste narzędzie, nie mamy wymówek, aby z niego nie korzystać.

Pamiętaj – jeśli widzisz, że dane w bazie są źle poukładane, staraj się jak najszybciej to naprawić. Im lepiej skonstruowana baza, z większym podziałem i oparciem na relacjach, tym przyjemniej się z nią w przyszłości pracuje.

Zadanie: wykorzystujemy wiedzę w praktyce

Zadanie 1

Korzystając z informacji podanych w submodule, zmodyfikuj endpointy do pobierania danych w employees.routes.js tak, aby w miejscu atrybutu department były zwracane odpowiadające dokumenty z kolekcji department.

Krótko mówiąc, oczekujemy, że endpointy będą zwracać dokumenty w takim formacie:

[
 {
   _id: '5d89da449adfcce066c6b643',
   firstName: 'Amanda',
   lastName: 'Doe',
   department: {
     id: '3254353412frsdfs',
     name: 'IT'
   }
 }
 ...
]

W zadaniu skorzystaj z metody populate().

Zadanie 2

Jak zapewne udało Ci się już zauważyć, nasze pliki z route'ami znacznie się rozrosły. Różnica względem tego, co otrzymaliśmy na początku, a co mamy teraz, jest spora.

Im większy plik, tym ciężej się po nim nawiguję. Wyobraź sobie, że chcesz np. zmienić tylko adres któregoś z endpointów. Aby znaleźć właściwy, bylibyśmy zmuszeni przekopać się przez kilkadziesiąt linijek kodu.

Jest jeszcze inna kwestia. Sama nazwa plików też jest myląca, bowiem .routes.js sugeruje, że w środku znajdą się same route'y, wskazujące tylko jaka funkcja ma się włączyć pod konkretnym adresem. Tymczasem u nas są tu także całe funkcje.

Twoim zadaniem jest więc wydzielenie funkcji od route'ów ze wszystkich .routes.js i przeniesienie ich do osobnych plików.

Idea będzie następująca.

Stworzymy kolejny folder (controllers), w którym będziemy trzymać pliki z grupami funkcji. Niech to będą osobne pliki dla każdej grupy route'ów, np. dla departments.routes.js stworzymy departments.controller.js, a dla products.routes.jsproducts.controller.js.

W tej chwili w plikach .routes.js do każdego endpointu jest przypięta rozbudowana anonimowa funkcja (req, res) => { ... }. Nasz pomysł jest taki, aby funkcje te nie były już anonimowe, lecz miały swoją nazwę i były przechowywane w odpowiadającym pliku w folderze controllers. W pliku z route'ami będziemy je tylko importować i przypinać do wybranych endpointów.

Dzięki takiemu zabiegowi znacznie uporządkujemy kod. Chcesz zmodyfikować jakąś funkcjonalność – wchodzisz do plików controllera. Jeśli zamierzasz zmienić tylko jakiś adres, robisz to w bardzo małym pliku z samymi route'ami.

Oczywiście nazywanie grup funkcji jako controllers nie jest obligatoryjne, to tylko jedna z idei widywana czasem w świecie IT. Równie dobrze możesz nazwać sobie ten folder methods, a pliki departments.methods.js.

Nie zapomnij też, że skoro funkcje będą w innym pliku, musimy przenieś do niego również import modeli. Zaciąganie ich w samych plikach z route'ami nie będzie miało już sensu.

Powyższe instrukcje mogą być dla Ciebie jeszcze nie do końca jasne. Aby łatwiej zrozumieć do czego dążymy, pokażemy, jak mógłby wyglądać ten proces modyfikacji na przykładzie departments.routes.js. Uwaga – pokazujemy tu wersję tego pliku przed modyfikacjami z zadania pierwszego.

Załóżmy, że plik departments.routes.js wygląda mniej więcej tak:

const express = require('express');
const router = express.Router();
const Department = require('../models/department.model');

router.get('/departments', async (req, res) => {
  try {
    res.json(await Department.find({}));
  }
  catch(err) {
    res.status(500).json({ message: err });
  }
});

router.get('/departments/random', async (req, res) => {

  try {
    const count = await Department.countDocuments();
    const rand = Math.floor(Math.random() * count);
    const dep = await Department.findOne().skip(rand);
    if(!dep) res.status(404).json({ message: 'Not found' });
    else res.json(dep);
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

...

Zgodnie z treścią zadania, utworzylibyśmy więc nowy plik /controllers/departments.controller.js i przenieśli logikę funkcji właśnie do niego. W pliku z route'ami następnie te funkcje byłyby importowane i wykorzystywane.

Całość wyglądałaby tak:

//departments.routes.js

const express = require('express');
const router = express.Router();

const DepartmentController = require('../controllers/departments.controller');

router.get('/departments', DepartmentController.getAll);
router.get('/departments/random', DepartmentController.getRandom);
...
//departments.controller.js

const Department = require('../models/department.model');

exports.getAll = async (req, res) => {
  try {
    res.json(await Department.find({}));
  }
  catch(err) {
    res.status(500).json({ message: err });
  }
});


exports.getRandom = async (req, res) => {

  try {
    const count = await Department.countDocuments();
    const rand = Math.floor(Math.random() * count);
    const dep = await Department.findOne().skip(rand);
    if(!dep) res.status(404).json({ message: 'Not found' });
    else res.json(dep);
  }
  catch(err) {
    res.status(500).json({ message: err });
  }

});

...

Końcowy efekt byłby zatem taki sam. Serwer pod odpowiednim endpointem wykonywałby dokładnie tę samą funkcjonalność i zwracał te same dane, lub komunikat. My jednak pracowalibyśmy na mniejszych częściach kodu, a wiesz już, że to zawsze dobra idea.

29.5. Mongoose w praktyce

Czas na sporą dawkę pracy własnej!

Po raz kolejny wrócimy do naszej witryny festiwalu muzycznego. Tym razem Twoim zadaniem będzie takie zmodyfikowanie całej aplikacji, aby zamiast tablicy, wykorzystywała bazy danych MongoDB. Tak naprawdę, nie będzie tu więc nic nowego, dokładnie taki sam proces przeprowadzaliśmy już na naszym gotowym serwerze API z poprzednich submodułów. Teraz będziemy mogli jednak ominąć etap z użyciem paczki mongodb i od razu skorzystamy z Mongoose.

Efektem ma być aplikacja, która działa tak samo, jak wcześniej, ale "pod maską" korzysta z baz danych MongoDB i paczki Mongoose.

Na bieżąco możesz korzystać z pomocy Mongo Shell, MongoDB Compass oraz Postmana.

Całą pracę możemy podzielić na kilka etapów.

Etap 1 – przygotowanie bazy danych

Twoim zadaniem będzie przygotowanie nowej bazy danych – NewWaveDB, dodanie do niej kolekcji testimonials, concerts, seats, a następnie wypełnienie jej tymi samymi danymi, które byłby używane na stronie dotychczas.

testimonials: [
  { id: 1, author: 'John Doe', text: 'This company is worth every coin!' },
  { id: 2, author: 'Amanda Doe', text: 'They really know how to make you happy.' }
],
concerts: [
  { id: 1, performer: 'John Doe', genre: 'Rock', price: 25, day: 1, image: '/img/uploads/1fsd324fsdg.jpg' },
  { id: 2, performer: 'Rebekah Parker', genre: 'R&B', price: 25, day: 1, image: '/img/uploads/2f342s4fsdg.jpg' },
  { id: 3, performer: 'Maybell Haley', genre: 'Pop', price: 40, day: 1, image: '/img/uploads/hdfh42sd213.jpg' }
],
seats: [
  { id: 1, day: 1, seat: 3, client: 'Amanda Doe', email: 'amandadoe@example.com' },
  { id: 2, day: 1, seat: 9, client: 'Curtis Johnson', email: 'curtisj@example.com'  },
  { id: 3, day: 1, seat: 10, client: 'Felix McManara', email: 'felxim98@example.com'  },
  { id: 4, day: 1, seat: 26, client: 'Fauna Keithrins', email: 'mefauna312@example.com'  },
  { id: 5, day: 2, seat: 1, client: 'Felix McManara', email: 'felxim98@example.com'  },
  { id: 6, day: 2, seat: 2, client: 'Molier Lo Celso', email: 'moiler.lo.celso@example.com'  }
]

Uwaga!

Przy wprowadzaniu danych, zastanów się, czy da się tę bazę jeszcze znormalizować. Może występują tutaj redundancje albo warto wydzielić jakąś kolejną kolekcję?

Normalizacja nie jest w tym zadaniu wymagana, ale jeśli uważasz, że czujesz się już pewnie w temacie, spróbuj się tym zająć.

Przy wykonaniu zadania z tego etapu możesz wspomóc się konsolą oraz programem MongoDB Compass.

mongoimport

Wprowadzanie tak dużej ilości danych może być czasochłonne, dlatego warto szukać jakiegoś prostego sposobu, który mógłby to przyśpieszyć.

MongoDB Compass oferuje opcję importu pliku JSON jako danych do kolekcji i w teorii właśnie tego szukamy. Moglibyśmy bowiem przygotować z naszych danych pliki JSON dla każdej kolekcji, a potem tylko je zaimportować. Niestety funkcja ta nie zawsze działa poprawnie.

Zamiast tego możesz skorzystać z podobnej funkcjonalności w samej konsoli Mongo Shell:

mongoimport --db <dbname> --collection <collection-name> --drop --file <filename>

Na przykład:

mongoimport --db booksDB --collection boooks --file booksData.json

Ma ona takie samo działanie. Importuje zawartość pliku JSON do wybranej kolekcji we wskazanej bazie danych.

Etap 2 – przygotowanie połączenie z bazą danych

Czas na przejście do aplikacji. Musisz tak zmodyfikować server.js, aby poprawnie łączył się z naszą bazą danych NewWaveDB. Naturalnie wykorzystaj do tego funkcję connect z Moongoose.

Etap 3 – przygotowanie modeli

Zanim przejdziemy do kolejnego kroku, a więc modyfikacji samych endpointów, musimy najpierw przygotować schematy struktur danych i modele, które się na nich opierają.

Podobnie jak w przypadku poprzednich submodułów, proponujemy, aby trzymać modele w oddzielnych plikach, w osobnym folderze /models. Jeśli jakieś dane powinny być według Ciebie powiązane relacją, nie zapomnij również o użyciu referencji.

Etap 4 – modyfikacja endpointów

Gdy modele są już gotowe, możesz wykorzystać je przy modyfikacji endpointów. Nie będziesz tutaj potrzebować żadnych nowych metod. Te, które już znasz (a poznaliśmy ich naprawdę sporo), spokojnie Ci wystarczą.

Etap 5 – wydzielenie controllerów

Na koniec, podobnie jak robiliśmy to już wcześniej, wydziel funkcje przypięte do route'ów do osobnych plików.

Zadanie: gotowe!

Gdy zadanie będzie już gotowe, wyślij zmiany na repozytorium, a link prześlij swojemu Mentorowi.

Uwaga – nie wrzucaj zmienionej wersji na Heroku, nie będzie on bowiem w stanie połączyć się z lokalną bazą danych. Ten przykład ma działać na razie tylko lokalnie.

29.6. MongoDB Atlas – zdalna baza danych

Jak wspomnieliśmy na końcu poprzedniego submodułu, lokalna baza danych ma jedną poważną wadę – może być używana tylko na naszym komputerze. Oczywiście w większości przypadków to nam nie wystarczy, bo strony czy aplikacje piszemy po to, aby udostępniać je w internecie. Na szczęście jest to problem, który łatwo możemy obejść. Wystarczy, że naszą bazę danych zamiast na lokalnym, postawimy na serwerze zdalnym.

MongoDB Atlas – zdalna baza danych

Hostować bazę danych zdalnie możemy na kilka sposobów, ale najprostszym z nich będzie skorzystanie z oficjalnej usługi – MongoDB Atlas. Pozwala ona na umieszczenie baz danych w chmurze.

Konfiguracja

Przejdźmy do rzeczy!

Zacznij od odwiedzenia oficjalnej strony tej usługi. Jeśli korzystasz z niej pierwszy raz (a tak prawdopodobnie jest), wybierz Start free i załóż nowe konto.

Darmowa opcja ma sporo ograniczeń, włącznie z dość małą przestrzenią na dane (512MB), dla nas będzie jednak wystarczająca. Jak widzisz, normalizacja danych w celu zaoszczędzenia miejsca może mieć swój sens.

Po zalogowaniu możemy zabrać się za przygotowanie clustera (czyli po prostu serwera) dla naszej bazy danych. Jeśli to Twoje pierwsze zetknięcie z tą platformą, automatycznie przekieruje Cię ona do zakładki Create New Cluster.

image

Zaczniemy od wyboru chmury, z której skorzystamy. Darmowe serwery znajdują się zarówno w opcji AWS (Amazon Web Services), Google Cloud Platform, jak i Azure (Microsoft). Do naszego przykładu, możesz zdecydować się na dowolną z nich. Następnie wybierz jeszcze jeden z darmowych clusterów.

Czy cluster jest darmowy?

To, czy dany cluster posiada jeszcze darmową przestrzeń, jest komunikowane poprzez etykietkę Free Tier Available. Wybieraj więc z clusterów, przy których widzisz właśnie ten komunikat.

Gdy wybierzesz już jedną z darmowych opcji, przejdź do zakładki Cluster Tier i upewnij się, że zaznaczono darmową opcję. Jeśli nie, to wybierz właśnie ją.

image

Teraz w dolnym boksie powinna widnieć informacja Free. Oznacza to, że możemy za darmo przejść dalej! Kliknij na przycisk Create Cluster, aby kontynuować.

image

Dodajemy użytkownika bazy

W następnym kroku platforma przekieruje nas do panelu administracyjnego. Sam cluster nie jest gotowy do pracy od zaraz. Jego przygotowanie przez system może zająć od 7 do 10 minut, o czym informuje nas sam dashboard.

image

Nie musimy jednak bezczynnie czekać. W tym czasie zajmiemy się dalszą konfiguracją i stworzymy użytkownika, który będzie łączył się z naszą bazą. Jest to operacja, którą proponuje dashboard.

image

Po kliknięciu na wskazany przycisk, panel sam zacznie wskazywać Ci drogę w odpowiednie miejsce. Jeśli jednak z jakiegoś powodu powyższy box nie jest u Ciebie dostępny, poniżej przedstawiamy również instrukcję tekstową:

  1. Wejdź w zakładkę Database Access.
  2. Następnie odnajdź przycisk Add New User i kliknij na niego.

Użytkownik bazy danych

Warto powiedzieć teraz kilka słów, o co w ogóle w tym chodzi. Bardzo często bazy danych mogą być obsługiwane przez kilka osób oraz różną ilość aplikacji. Niekoniecznie chcielibyśmy, aby każda z nich miała pełny dostęp do wszystkich operacji. Wyobraź sobie, że zatrudniasz stażystę, który ma wprowadzić do bazy jakąś kolekcję rekordów. Czy chcielibyśmy, aby miał dostęp do operacji delete czy drop? Raczej nie. Pewnie wolelibyśmy nadać mu tylko uprawnienia do używania insertMany i insertOne.

Każdy użytkownik może mieć inne uprawnienia, dzięki czemu jesteśmy w stanie decydować komu i w jakim zakresie zezwalamy na pracę z naszą bazą danych.

Po wybraniu tej opcji Twoim oczom ukaże się panel Add New User:

image

Wybór nazwy oraz hasła pozostawiamy Tobie. Jeśli chodzi o wybranie opcji dostępu, nasz użytkownik potrzebuje pełni możliwości. Musi być w stanie dodawać, modyfikować, ale również i usuwać dane. Wybierz więc opcję Atlas Admin.

Następnie kliknij na przycisk Add User, aby dokończyć operację. Jeśli wszystko poszło dobrze, to panel w aktualnej liście użytkowników, pokaże również tego, który właśnie został utworzony.

image

Akceptowanie połączeń z zewnątrz

Po dodaniu użytkownika dashboard proponuje nam kolejny krok – Whitelist your IP address. O co w tym chodzi?

Zdalny serwer bazy danych będzie musiał być dostępny dla naszej aplikacji w dwóch miejscach – lokalnie oraz na serwerze Heroku. Nie chcielibyśmy jednak, aby inne serwery również miały do niego dostęp. Platforma pozwala więc decydować, kto może się z nim połączyć. Służy do tego właśnie funkcjonalność białej listy.

Kliknij więc w przycisk Whitelist your IP address w boksie Get Started i podążaj za instrukcjami pokazywanymi przez panel. Ponownie, jeśli nie widzisz tego boksa, poniżej przedstawiamy instrukcję tekstową:

  1. Wejdź w zakładkę Network Access (blok Security).
  2. Kliknij na przycisk Add IP address.

Twoim oczom powinien ukazać się poniższy panel:

image

Mamy tu kilka możliwości:

  • Możemy ustawić opcję Allow access from anywhere, która pozwala na dostęp dla każdego. Jest to dość wygodne na czas testów, na pewno nie chcielibyśmy jednak zostawiać tego dla gotowej aplikacji.
  • Istnieje również opcja Add current IP address, która powinna ustalić Twój adres IP i zezwolić na dostęp właśnie z niego. Może to być dobry wybór przy próbie przetestowania, czy nasza aplikacja będzie w stanie połączyć się z clusterem lokalnie.
  • Trzecia możliwość to wpisanie adresu IP ręcznie. Jest to idealna opcja, gdy posiadamy już serwer zdalny dla naszej aplikacji i znamy jego adres IP.

Hosting Heroku ma tę wadę, że niestety w darmowym pakiecie nie oferuje stałego IP i nie możemy niestety skorzystać z potencjalnie najlepszej trzeciej opcji. Zamiast tego wybierz pierwszą – Allow access from anywhere. Niemniej jednak w komercyjnym projekcie, skorzystalibyśmy z opcji numer 3.

Ładowanie początkowych danych

Dashboard proponuje jeszcze załadowanie testowych danych, my jednak wolelibyśmy dodać swoje własne, najlepiej dokładnie takie, jakie mamy teraz lokalnie. To będzie już Twoim zadaniem na później.

Proces konfiguracji MongoDB Atlas dobiegł końca.

MongoDB Atlas – integracja z naszą aplikacją

Nasza aplikacja nadal korzysta z lokalnej bazy danych MongoDB, ale naszedł czas, by to zmienić!

Zaczniemy od sprawdzenia jaki jest link do serwera bazy danych. W dashboardzie MongoDB Atlas, kliknij opcję Connect to Your Cluster w boksie Get Started i podążaj za wskazówkami w panelu, lub przeczytaj naszą instrukcję tekstową:

  1. Wejdź w zakładkę Clusters.
  2. Odnajdź swój cluster.
  3. Kliknij na przycisk Connect.

Uwaga!

Aby połączenie stało się możliwe, cluster musi być już przygotowany. Jeśli więc w tym miejscu wciąż pojawia się komunikat o tworzeniu clustera przez system, odczekaj, aż będzie on gotowy.

Po wybraniu opcji Connect panel pokaże nam następujące okno:

image

Możemy zdecydować, jaką opcję połączenia zastosujemy. Wybierz Connect Your Application. Następna zakładka zapyta nas o wybrany silnik, który został zastosowany w naszej aplikacji, musimy też wskazać jego wersję. U nas to oczywiście Node.js i wersja nowsza niż 3.0.

image

W wyniku otrzymujemy "Connection String", który wykorzystamy za chwilę do integracji naszej aplikacji ze zdalną bazą danych.

image

Zadanie: zmieniamy bazę danych

Zdalna baza danych już istnieje. Twoim zadaniem jest teraz połączenie się z nią w naszej aplikacji (zamiast z bazą lokalną) oraz załadowanie wszystkich danych.

Zadanie 1

Pierwsze zadanie będzie stosunkowo proste. Masz już bowiem przygotowany "Connection String", wystarczy wstawić go w miejsce naszego adresu lokalnego w funkcji connect.

Od tej chwili aplikacja nie będzie korzystać już lokalnej bazy danych, tylko zdalnej. Spróbuj uruchomić serwer i sprawdź, czy nie pokaże żadnych błędów.

Uwaga

Przechowywanie "Connection String" w pliku (np. server.js) nie jest idealnym rozwiązaniem, bo taki plik jest przecież wysyłany na repo (często publiczne). Na razie tym się nie przejmuj, jak zrobić to inaczej opowiemy w kolejnych częściach kursu, gdy wspomnimy o bezpieczeństwie aplikacji.

Zadanie 2

Niestety nasza zdalna baza danych jest jeszcze pusta. Twoim zadaniem jest dodanie do niej odpowiednich informacji.

Możesz to robić pojedynczo, wchodząc w zakładkę Clusters, a potem wybierając opcję Collections. MongoDB Atlas pozwala na bardzo podobne operowanie bazą jak MongoDB Compass.

Możesz także skusić się na opcję importu z plików JSON. Więcej o tym tutaj.

Jak to zrobisz, zależy tylko od Ciebie. Efekt powinien być jednak taki, iż w zdalnej bazie danych będą dokładnie te same kolekcje i dane, jakie mieliśmy dotychczas w lokalnej.

29.7. Podsumowanie

Przed tym modułem mogło Ci się wydawać, że bazy danych tak naprawdę nie są nam do niczego potrzebne. W końcu pisaliśmy już aplikacje i jakoś sobie bez nich radziliśmy.

Mamy nadzieję jednak, że niniejszy moduł zmienił Twoje postrzeganie. Bazy danych są potrzebne, a często wręcz niezbędne i od tego tematu zwyczajnie nie da się uciec. Prędzej czy później natrafisz na takie sytuacje, w których brak zaawansowanej bazy danych będzie nie do przejścia.

Chyba zgodzisz się z tym, że bazy danych (w wydaniu MongoDB) wcale nie są aż tak straszne w odbiorze. Z pomocą Mongoose byliśmy w stanie operować na nich dość łatwo, czasem nawet prościej niż na zwykłych tablicach. Dodawanie danych, usuwanie, modyfikacja – wszystko sprowadzało się do bardzo intuicyjnych komend. MongoDB wraz z Mongoose możemy uznać więc za narzędzie nie tylko potrzebne, ale też bardzo miłe w odbiorze, które chętnie będziesz wykorzystywać również w przyszłości.

Z modułu na moduł, możesz mieć wrażenie, że nasze aplikacje stają się coraz bardziej rozbudowane. Dzięki temu ich możliwości są większe, ale też coraz częściej nie czujemy się już w nich pewnie. Przydałyby się jakieś testy, prawda? Właśnie tym, a więc testowaniem kodu po stronie backendu, zajmiemy się w kolejnym module.

29.8. Quiz powtórkowy

Na koniec tego modułu przygotowaliśmy dla Ciebie quiz powtórkowy. Pomoże Ci on powtórzyć wiedzę z poprzednich modułów.

Odpowiedzi tego quizu nie są nigdzie zapisywane, więc są tylko do Twojej wiadomości. Ten quiz ma Ci posłużyć jako pomoc w nauce – dlatego pod każdym pytaniem znajdziesz guzik, który sprawdzi poprawność Twoich odpowiedzi oraz poda Ci wyjaśnienie zagadnienia poruszanego w tym pytaniu.

Tym razem tylko jedno pytanie, ale za to wymagające odrobiny przemyślenia...

Wybierz przypadki, w których warto skorzystać z WebSocketa:

Wyjaśnienie

To pytanie jest bardzo trudne, więc nie przejmuj się, jeśli nie udało Ci się zaznaczyć wszystkich poprawnych odpowiedzi.

Cały problem polega na tym, że teoretycznie każdy z tych przypadków można obsłużyć bez WebSocketa, opierając się wyłącznie na zapytaniach ajaxowych. Z drugiej strony, moglibyśmy stosować WebSocket do wszystkich połączeń z serwerem. Jak w takim razie możemy znaleźć złoty środek?

Warto pamiętać, że aplikacja oparta o WebSocket będzie zwykle droższa w utrzymaniu. W związku z tym zdecydujemy się na to rozwiązanie tylko wtedy, kiedy dla użytkownika kluczowa będzie prędkość komunikacji.

Zacznijmy od czatu i gry multiplayer – w tym wypadku wiele osób komunikuje się ze sobą i będą oczekiwać, że komunikacja będzie "na żywo", więc na pewno warto by było zastosować WebSocket.

Z drugiej strony mamy stronę produktu w sklepie, artykuł na blogu, prognozę pogody czy ankietę – tu treści będą zmieniać się bardzo rzadko, a serwer rzadko będzie musiał powiadomić klienta o jakiejś zmianie.

Największy problem mielibyśmy przy decyzji dotyczącej aukcji internetowej czy panelu rezerwacji miejsc w kinie – tutaj WebSocket pozwoliłby na zwiększenie wygody użytkowania, ale z drugiej strony wymagałby znacznie większych nakładów. O ile ostateczna decyzja byłaby trudna, to możemy przynajmniej powiedzieć, że użycie WebSocketa dałoby jakąś wartość – i dlatego oznaczyliśmy te odpowiedzi jako poprawne.

;